import json import sys from pathlib import Path import httpx import pytest import respx from mcp.server.mcpserver import AcceptedElicitation, DeclinedElicitation from mcp.shared.exceptions import MCPError sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import server # noqa: E402 import trello # noqa: E402 LISTS_URL = "https://api.trello.com/1/boards/board1/lists" CARDS_URL = "https://api.trello.com/1/cards" BOARD_LISTS = [ {"id": "list-backlog", "name": "Бэклог"}, {"id": "list-bugs", "name": "Баги с прода"}, {"id": "list-done", "name": "Готово"}, ] @pytest.fixture(autouse=True) def trello_env(monkeypatch): monkeypatch.setenv("TRELLO_API_KEY", "key123") monkeypatch.setenv("TRELLO_TOKEN", "tok456") monkeypatch.setenv("TRELLO_BOARD_ID", "board1") monkeypatch.setenv("TRELLO_CREATE_LISTS", " Бэклог , Баги с прода,") class FakeContext: def __init__(self, answer=None, error=None): self.answer = answer self.error = error self.messages = [] async def elicit(self, message, schema): self.messages.append(message) if self.error: raise self.error return self.answer def test_create_targets_from_env(): assert trello.create_targets() == ["Бэклог", "Баги с прода"] @pytest.mark.asyncio async def test_resolve_rejects_list_not_in_env(): # «Готово» есть на доске, но не разрешён — в API даже не ходим. with respx.mock: with pytest.raises(ValueError, match="не разрешён"): await trello.resolve_create_list("Готово") @pytest.mark.asyncio async def test_resolve_finds_list_case_insensitive(): with respx.mock: respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS)) target = await trello.resolve_create_list("баги С прода") assert target == {"id": "list-bugs", "name": "Баги с прода"} @pytest.mark.asyncio async def test_resolve_list_missing_on_board(): with respx.mock: respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS[2:])) with pytest.raises(ValueError, match="не найден"): await trello.resolve_create_list("Бэклог") @pytest.mark.asyncio async def test_confirmed_card_is_created_with_exact_text(): ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=True))) with respx.mock: respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS)) create = respx.post(CARDS_URL).mock( return_value=httpx.Response( 200, json={"id": "c1", "name": "Падает экспорт", "shortUrl": "https://trello.com/c/xyz"} ) ) result = await server.create_card_confirmed(ctx, "Бэклог", "Падает экспорт", "Шаги: ...") assert json.loads(create.calls.last.request.content) == { "idList": "list-backlog", "name": "Падает экспорт", "desc": "Шаги: ...", } assert result == { "created": True, "list": "Бэклог", "template": None, "labels": [], "id": "c1", "name": "Падает экспорт", "url": "https://trello.com/c/xyz", } assert "Падает экспорт" in ctx.messages[0] and "Шаги: ..." in ctx.messages[0] @pytest.mark.parametrize( "ctx", [ FakeContext(DeclinedElicitation()), FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=False))), FakeContext(error=MCPError(code=-32601, message="elicitation not supported")), ], ) @pytest.mark.asyncio async def test_card_not_created_without_confirmation(ctx): with respx.mock: respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS)) create = respx.post(CARDS_URL) result = await server.create_card_confirmed(ctx, "Бэклог", "T", "D") assert result["created"] is False assert not create.called BOARD_CARDS_URL = "https://api.trello.com/1/boards/board1/cards" BOARD_CARDS = [ {"id": "card-plain", "name": "Обычная", "desc": "", "idList": "list-backlog", "labels": [], "isTemplate": False}, { "id": "tpl-bug", "name": "баг", "desc": "## Шаги\n## Ожидаемо", "idList": "list-backlog", "labels": [{"name": "Bug", "color": "red"}], "isTemplate": True, "idMembers": ["m1"], "cover": {"color": "red", "size": "normal", "brightness": "light", "yPosition": 0.5}, }, # Архивный шаблон в архивном списке — тоже нужен. {"id": "tpl-old", "name": "старый", "desc": "", "idList": "list-archived", "labels": [], "isTemplate": True}, ] TEMPLATE_CHECKLISTS = { "tpl-bug": [{"name": "DoD", "checkItems": [{"name": "Тесты"}]}], "tpl-old": [], } ALL_LISTS = BOARD_LISTS + [{"id": "list-archived", "name": "How to use this Board"}] BOARD_MEMBERS_URL = "https://api.trello.com/1/boards/board1/members" BOARD_LABELS_URL = "https://api.trello.com/1/boards/board1/labels" BOARD_LABELS = [ {"id": "lb-bug", "name": "bug", "color": "orange", "uses": 102}, {"id": "lb-np", "name": "Нова Пошта", "color": "yellow_light", "uses": 41}, {"id": "lb-np-old", "name": "нова пошта", "color": "yellow", "uses": 1}, {"id": "lb-empty", "name": "", "color": "purple", "uses": 2}, ] def _mock_board(): respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=ALL_LISTS)) respx.get(BOARD_MEMBERS_URL).mock( return_value=httpx.Response(200, json=[{"id": "m1", "fullName": "Alexey Poddubniy"}]) ) respx.get(BOARD_LABELS_URL).mock(return_value=httpx.Response(200, json=BOARD_LABELS)) for card_id, checklists in TEMPLATE_CHECKLISTS.items(): respx.get(f"{CARDS_URL}/{card_id}/checklists").mock( return_value=httpx.Response(200, json=checklists) ) return respx.get(BOARD_CARDS_URL).mock(return_value=httpx.Response(200, json=BOARD_CARDS)) @pytest.mark.asyncio async def test_list_templates_only_template_cards(): with respx.mock: cards = _mock_board() templates = await trello.list_templates() assert cards.calls.last.request.url.params["filter"] == "all" assert templates == [ { "id": "tpl-bug", "name": "баг", "list": "Бэклог", "desc": "## Шаги\n## Ожидаемо", "members": ["Alexey Poddubniy"], "cover": {"color": "red", "size": "normal", "brightness": "light"}, "labels": [{"name": "Bug", "color": "red"}], "checklists": [{"name": "DoD", "items": ["Тесты"]}], }, { "id": "tpl-old", "name": "старый", "list": "How to use this Board", "desc": "", "members": [], "cover": None, "labels": [], "checklists": [], }, ] @pytest.mark.asyncio async def test_resolve_template_unknown_lists_available(): with respx.mock: _mock_board() with pytest.raises(ValueError, match="Есть: баг, старый"): await trello.resolve_template("Фича") @pytest.mark.asyncio async def test_card_from_template_copies_and_fixes_desc_and_flag(): ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation())) with respx.mock: _mock_board() create = respx.post(CARDS_URL).mock( return_value=httpx.Response( 200, json={ "id": "c1", "name": "Падает экспорт", "shortUrl": "https://trello.com/c/xyz", # Trello скопировал описание и флаг шаблона. "desc": "## Шаги\n## Ожидаемо", "isTemplate": True, "idLabels": ["lb-tpl"], }, ) ) fix = respx.put(f"{CARDS_URL}/c1").mock(return_value=httpx.Response(200, json={})) result = await server.create_card_confirmed( ctx, "Бэклог", "Падает экспорт", "## Шаги\n1. Нажать", template_name="БАГ", label_names=["НОВА ПОШТА", "bug"], ) sent = json.loads(create.calls.last.request.content) assert sent["idCardSource"] == "tpl-bug" assert {"checklists", "labels", "members"} <= set(sent["keepFromSource"].split(",")) assert json.loads(fix.calls.last.request.content) == { "desc": "## Шаги\n1. Нажать", "isTemplate": False, "cover": {"color": "red", "size": "normal", "brightness": "light"}, # Метки шаблона сохраняются, выбранные добавляются; при дублях # названий берётся самая используемая метка. "idLabels": "lb-tpl,lb-np,lb-bug", } assert result["template"] == "баг" assert result["labels"] == ["Нова Пошта", "bug"] preview = ctx.messages[0] assert "Шаблон: баг" in preview and "чек-лист «DoD»" in preview assert "участники: Alexey Poddubniy" in preview and "обложка: red" in preview assert "Метки: Нова Пошта, bug" in preview @pytest.mark.asyncio async def test_list_labels_skips_unnamed_and_sorts(): with respx.mock: _mock_board() labels = await trello.list_labels() assert [label["name"] for label in labels] == ["bug", "Нова Пошта", "нова пошта"] @pytest.mark.asyncio async def test_unknown_label_fails_before_confirmation(): ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation())) with respx.mock: _mock_board() create = respx.post(CARDS_URL) with pytest.raises(ValueError, match="'Нет такой'"): await server.create_card_confirmed( ctx, "Бэклог", "T", "D", label_names=["bug", "Нет такой"] ) assert not ctx.messages and not create.called def test_confirmation_checked_by_default(): assert server.CardConfirmation().confirm is True