import json import sys from pathlib import Path import httpx import pytest import respx from mcp import types from mcp.client import Client from mcp.server.mcpserver import AcceptedElicitation, CancelledElicitation, DeclinedElicitation 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", " Бэклог , Баги с прода,") ACCEPTED = AcceptedElicitation(data=server.CardConfirmation()) 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(): 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"} ) ) plan = await server.resolve_card_plan("Бэклог") question = await server.ask_card_confirmation("Падает экспорт", "Шаги: ...", plan) result = await server.create_card_confirmed(plan, "Падает экспорт", "Шаги: ...", ACCEPTED) 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 question.schema is server.CardConfirmation assert "Падает экспорт" in question.message and "Шаги: ..." in question.message @pytest.mark.parametrize( "answer", [ DeclinedElicitation(), CancelledElicitation(), AcceptedElicitation(data=server.CardConfirmation(confirm=False)), ], ) @pytest.mark.asyncio async def test_card_not_created_without_confirmation(answer): with respx.mock: respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS)) create = respx.post(CARDS_URL) plan = await server.resolve_card_plan("Бэклог") result = await server.create_card_confirmed(plan, "T", "D", answer) assert result["created"] is False assert not create.called # На 2026-07-28 сервер не может сам слать запросы клиенту: окно идёт через # input_required и повторный вызов тула. ctx.elicit там падал с # NoBackChannelError, и карточки не создавались вообще. @pytest.mark.parametrize("mode", ["legacy", "2026-07-28"]) @pytest.mark.asyncio async def test_tool_asks_user_on_both_protocols(mode): asked = [] async def answer(context, params): asked.append(params.message) return types.ElicitResult(action="accept", content={"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": "T", "shortUrl": "https://trello.com/c/xyz"}) ) async with Client(server.mcp, mode=mode, elicitation_callback=answer) as client: result = await client.call_tool( "trello_create_card", {"list_name": "Бэклог", "name": "T", "desc": "D"} ) assert not result.is_error, result assert json.loads(result.content[0].text)["created"] is True assert create.call_count == 1 assert len(asked) == 1 and "Название: T" in asked[0] @pytest.mark.parametrize("mode", ["legacy", "2026-07-28"]) @pytest.mark.asyncio async def test_tool_creates_nothing_when_user_declines(mode): async def answer(context, params): return types.ElicitResult(action="decline") with respx.mock: respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS)) create = respx.post(CARDS_URL) async with Client(server.mcp, mode=mode, elicitation_callback=answer) as client: result = await client.call_tool( "trello_create_card", {"list_name": "Бэклог", "name": "T", "desc": "D"} ) assert json.loads(result.content[0].text)["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(): 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={})) plan = await server.resolve_card_plan("Бэклог", "БАГ", ["НОВА ПОШТА", "bug"]) question = await server.ask_card_confirmation("Падает экспорт", "## Шаги\n1. Нажать", plan) result = await server.create_card_confirmed( plan, "Падает экспорт", "## Шаги\n1. Нажать", ACCEPTED ) 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 = question.message 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(): asked = [] async def answer(context, params): asked.append(params.message) return types.ElicitResult(action="accept", content={"confirm": True}) with respx.mock: _mock_board() create = respx.post(CARDS_URL) async with Client(server.mcp, mode="2026-07-28", elicitation_callback=answer) as client: result = await client.call_tool( "trello_create_card", {"list_name": "Бэклог", "name": "T", "desc": "D", "labels": ["bug", "Нет такой"]}, ) assert result.is_error and "'Нет такой'" in result.content[0].text assert not asked and not create.called def test_confirmation_checked_by_default(): assert server.CardConfirmation().confirm is True