trello-tools: плагин и маркетплейс gp-tools для Claude Code
Сервер запускается через uv run --script, ключи запрашиваются при установке плагина (userConfig) вместо .env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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
|
||||
@@ -0,0 +1,78 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp.server.mcpserver import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def _fake_attachment(monkeypatch, **fields):
|
||||
attachment = {
|
||||
"id": "a1",
|
||||
"name": "f",
|
||||
"kind": "file",
|
||||
"mime": None,
|
||||
"size": 0,
|
||||
"date": None,
|
||||
"url": "https://trello.com/1/x",
|
||||
**fields,
|
||||
}
|
||||
|
||||
async def fake_get_attachment(card_id, attachment_id):
|
||||
return dict(attachment)
|
||||
|
||||
monkeypatch.setattr(server.trello, "get_attachment", fake_get_attachment)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_png_returned_as_image(monkeypatch):
|
||||
_fake_attachment(monkeypatch, kind="image", mime="image/png", data=b"\x89PNG", path="/tmp/x.png")
|
||||
|
||||
meta, image = await server.trello_get_attachment("card1", "a1")
|
||||
|
||||
assert isinstance(image, Image)
|
||||
assert "data" not in meta and meta["path"] == "/tmp/x.png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_svg_only_path(monkeypatch):
|
||||
_fake_attachment(monkeypatch, kind="image", mime="image/svg+xml", data=b"<svg/>", path="/tmp/x.svg")
|
||||
|
||||
result = await server.trello_get_attachment("card1", "a1")
|
||||
|
||||
assert isinstance(result, dict) and result["path"] == "/tmp/x.svg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_text_file_inlined(monkeypatch):
|
||||
_fake_attachment(monkeypatch, mime="text/plain", data="ошибка 500".encode(), path="/tmp/x.txt")
|
||||
|
||||
result = await server.trello_get_attachment("card1", "a1")
|
||||
|
||||
assert result["text"] == "ошибка 500"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pdf_only_path(monkeypatch):
|
||||
_fake_attachment(monkeypatch, mime="application/pdf", data=b"%PDF", path="/tmp/x.pdf")
|
||||
|
||||
result = await server.trello_get_attachment("card1", "a1")
|
||||
|
||||
assert "text" not in result and "data" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linked_card_fetched(monkeypatch):
|
||||
_fake_attachment(monkeypatch, kind="card", url="https://trello.com/c/zzz/1-x")
|
||||
|
||||
async def fake_get_card(url):
|
||||
return {"id": "linked", "url": url}
|
||||
|
||||
monkeypatch.setattr(server.trello, "get_card", fake_get_card)
|
||||
|
||||
result = await server.trello_get_attachment("card1", "a1")
|
||||
|
||||
assert result["card"] == {"id": "linked", "url": "https://trello.com/c/zzz/1-x"}
|
||||
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import qa_client # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def qa_env(monkeypatch):
|
||||
monkeypatch.setenv("QA_SERVICE_URL", "https://qa.example.com")
|
||||
monkeypatch.setenv("QA_SERVICE_API_KEY", "svc-key-789")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_doc_posts_to_docs():
|
||||
with respx.mock:
|
||||
route = respx.post("https://qa.example.com/docs").mock(
|
||||
return_value=httpx.Response(200, json={"slug": "abc", "url": "https://qa.example.com/docs/abc"})
|
||||
)
|
||||
result = await qa_client.publish_doc("Заголовок", "текст", "https://trello.com/c/x")
|
||||
|
||||
assert route.called
|
||||
request = route.calls.last.request
|
||||
assert request.headers["X-API-Key"] == "svc-key-789"
|
||||
assert result == {"slug": "abc", "url": "https://qa.example.com/docs/abc"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_doc_puts_to_docs_slug():
|
||||
with respx.mock:
|
||||
route = respx.put("https://qa.example.com/docs/abc").mock(
|
||||
return_value=httpx.Response(200, json={"slug": "abc", "url": "https://qa.example.com/docs/abc"})
|
||||
)
|
||||
result = await qa_client.update_doc(
|
||||
"abc", "Новый заголовок", "новый текст", "https://trello.com/c/x"
|
||||
)
|
||||
|
||||
assert route.called
|
||||
request = route.calls.last.request
|
||||
assert request.headers["X-API-Key"] == "svc-key-789"
|
||||
assert result == {"slug": "abc", "url": "https://qa.example.com/docs/abc"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_doc_missing_slug_raises():
|
||||
with respx.mock:
|
||||
respx.put("https://qa.example.com/docs/does-not-exist").mock(
|
||||
return_value=httpx.Response(404, json={"detail": "Doc not found"})
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await qa_client.update_doc("does-not-exist", "T", "текст")
|
||||
|
||||
|
||||
def test_update_doc_requires_config(monkeypatch):
|
||||
monkeypatch.delenv("QA_SERVICE_URL", raising=False)
|
||||
monkeypatch.delenv("QA_SERVICE_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(qa_client.QaServiceConfigError):
|
||||
qa_client._config()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_fetches_raw_by_slug_or_url():
|
||||
doc = {"slug": "abc", "markdown": "текст", "scenarios": []}
|
||||
with respx.mock:
|
||||
route = respx.get("https://qa.example.com/docs/abc/raw").mock(
|
||||
return_value=httpx.Response(200, json=doc)
|
||||
)
|
||||
by_slug = await qa_client.get_doc("abc")
|
||||
by_url = await qa_client.get_doc("https://qa.example.com/docs/abc/")
|
||||
|
||||
assert route.call_count == 2
|
||||
assert route.calls.last.request.headers["X-API-Key"] == "svc-key-789"
|
||||
assert by_slug == by_url == doc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_doc_sends_problem():
|
||||
with respx.mock:
|
||||
route = respx.post("https://qa.example.com/docs").mock(
|
||||
return_value=httpx.Response(200, json={"slug": "abc", "url": "u"})
|
||||
)
|
||||
await qa_client.publish_doc("T", "текст", problem="Падает экспорт")
|
||||
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
assert body["problem"] == "Падает экспорт"
|
||||
assert body["card_url"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_task_uses_bearer_api_key():
|
||||
with respx.mock:
|
||||
route = respx.post("https://qa.example.com/tasks").mock(
|
||||
return_value=httpx.Response(200, json={"slug": "t1"})
|
||||
)
|
||||
await qa_client.publish_task("front", "Кнопка", "текст", "https://trello.com/c/x")
|
||||
|
||||
request = route.calls.last.request
|
||||
assert request.headers["Authorization"] == "Bearer svc-key-789"
|
||||
assert json.loads(request.content) == {
|
||||
"title": "Кнопка",
|
||||
"markdown": "текст",
|
||||
"target": "front",
|
||||
"card_url": "https://trello.com/c/x",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_work_with_tasks_token_only(monkeypatch):
|
||||
# Claude на фронте: основного ключа нет, только токен заданий.
|
||||
monkeypatch.delenv("QA_SERVICE_API_KEY")
|
||||
monkeypatch.setenv("QA_TASKS_TOKEN", "tasks-tok")
|
||||
|
||||
with respx.mock:
|
||||
route = respx.get("https://qa.example.com/tasks/t1/raw").mock(
|
||||
return_value=httpx.Response(200, json={"slug": "t1"})
|
||||
)
|
||||
await qa_client.get_task("https://qa.example.com/tasks/t1")
|
||||
|
||||
assert route.calls.last.request.headers["Authorization"] == "Bearer tasks-tok"
|
||||
with pytest.raises(qa_client.QaServiceConfigError):
|
||||
qa_client._config()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_and_list_tasks():
|
||||
with respx.mock:
|
||||
put = respx.put("https://qa.example.com/tasks/t1").mock(
|
||||
return_value=httpx.Response(200, json={"slug": "t1"})
|
||||
)
|
||||
listing = respx.get("https://qa.example.com/tasks").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
await qa_client.update_task("t1", "back", "T", "v2")
|
||||
await qa_client.list_tasks(card_url="https://trello.com/c/abc", target=None)
|
||||
|
||||
assert json.loads(put.calls.last.request.content)["target"] == "back"
|
||||
assert dict(listing.calls.last.request.url.params) == {"card": "https://trello.com/c/abc"}
|
||||
@@ -0,0 +1,266 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import trello # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected",
|
||||
[
|
||||
("https://trello.com/c/abc123/45-название-карточки", "abc123"),
|
||||
("https://trello.com/c/XyZ987", "XyZ987"),
|
||||
],
|
||||
)
|
||||
def test_parse_short_link_ok(url, expected):
|
||||
assert trello.parse_short_link(url) == expected
|
||||
|
||||
|
||||
def test_parse_short_link_invalid():
|
||||
with pytest.raises(ValueError):
|
||||
trello.parse_short_link("https://example.com/not-a-trello-link")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_card_builds_request(monkeypatch):
|
||||
monkeypatch.setenv("TRELLO_API_KEY", "key123")
|
||||
monkeypatch.setenv("TRELLO_TOKEN", "tok456")
|
||||
|
||||
with respx.mock:
|
||||
route = respx.get("https://api.trello.com/1/cards/abc123").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "5f0000000000000000000001",
|
||||
"name": "Карточка",
|
||||
"desc": "Описание",
|
||||
"shortUrl": "https://trello.com/c/abc123",
|
||||
"idBoard": "board1",
|
||||
"idList": "list1",
|
||||
"labels": [
|
||||
{"id": "l1", "name": "Баг", "color": "red"},
|
||||
{"id": "l2", "name": "", "color": "green"},
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "a1",
|
||||
"name": "screen.png",
|
||||
"url": "https://trello.com/1/cards/c/attachments/a1/download/screen.png",
|
||||
"mimeType": "image/png",
|
||||
"bytes": 1024,
|
||||
"isUpload": True,
|
||||
"date": "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
"id": "a2",
|
||||
"name": "Связанная",
|
||||
"url": "https://trello.com/c/zzz999/7-linked",
|
||||
"mimeType": "",
|
||||
"bytes": None,
|
||||
"isUpload": False,
|
||||
"date": "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
actions_route = respx.get(
|
||||
"https://api.trello.com/1/cards/abc123/actions"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"data": {"text": "второй комментарий"},
|
||||
"date": "2026-01-02T00:00:00.000Z",
|
||||
"memberCreator": {"fullName": "Аня"},
|
||||
},
|
||||
{
|
||||
"data": {"text": "первый комментарий"},
|
||||
"date": "2026-01-01T00:00:00.000Z",
|
||||
"memberCreator": {"fullName": "Максим"},
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
result = await trello.get_card("https://trello.com/c/abc123/45-title")
|
||||
|
||||
assert route.called
|
||||
assert actions_route.called
|
||||
request = route.calls.last.request
|
||||
assert request.url.params["key"] == "key123"
|
||||
assert request.url.params["token"] == "tok456"
|
||||
assert request.url.params["attachments"] == "true"
|
||||
assert result.pop("labels") == [
|
||||
{"name": "Баг", "color": "red"},
|
||||
{"name": "", "color": "green"},
|
||||
]
|
||||
attachments = result.pop("attachments")
|
||||
assert [(a["id"], a["kind"]) for a in attachments] == [("a1", "image"), ("a2", "card")]
|
||||
assert attachments[0]["mime"] == "image/png" and attachments[0]["size"] == 1024
|
||||
assert result == {
|
||||
"id": "5f0000000000000000000001",
|
||||
"name": "Карточка",
|
||||
"desc": "Описание",
|
||||
"url": "https://trello.com/c/abc123",
|
||||
"board_id": "board1",
|
||||
"list_id": "list1",
|
||||
"comments": [
|
||||
{"author": "Максим", "text": "первый комментарий", "date": "2026-01-01T00:00:00.000Z"},
|
||||
{"author": "Аня", "text": "второй комментарий", "date": "2026-01-02T00:00:00.000Z"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_comments_builds_request(monkeypatch):
|
||||
monkeypatch.setenv("TRELLO_API_KEY", "key123")
|
||||
monkeypatch.setenv("TRELLO_TOKEN", "tok456")
|
||||
|
||||
with respx.mock:
|
||||
route = respx.get("https://api.trello.com/1/cards/card1/actions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"data": {"text": "коммент без автора"},
|
||||
"date": "2026-01-01T00:00:00.000Z",
|
||||
"memberCreator": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
result = await trello.get_comments("card1")
|
||||
|
||||
assert route.called
|
||||
request = route.calls.last.request
|
||||
assert request.url.params["filter"] == "commentCard"
|
||||
assert result == [
|
||||
{"author": "?", "text": "коммент без автора", "date": "2026-01-01T00:00:00.000Z"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_comment_builds_request(monkeypatch):
|
||||
monkeypatch.setenv("TRELLO_API_KEY", "key123")
|
||||
monkeypatch.setenv("TRELLO_TOKEN", "tok456")
|
||||
|
||||
with respx.mock:
|
||||
route = respx.post(
|
||||
"https://api.trello.com/1/cards/card1/actions/comments"
|
||||
).mock(return_value=httpx.Response(200, json={"id": "comment1"}))
|
||||
|
||||
result = await trello.add_comment("card1", "QA: https://qa.example/docs/xyz")
|
||||
|
||||
assert route.called
|
||||
request = route.calls.last.request
|
||||
assert request.url.params["text"] == "QA: https://qa.example/docs/xyz"
|
||||
assert result == {"id": "comment1"}
|
||||
|
||||
|
||||
def test_get_card_requires_credentials(monkeypatch):
|
||||
monkeypatch.delenv("TRELLO_API_KEY", raising=False)
|
||||
monkeypatch.delenv("TRELLO_TOKEN", raising=False)
|
||||
|
||||
with pytest.raises(trello.TrelloConfigError):
|
||||
trello._credentials()
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attachment, kind",
|
||||
[
|
||||
({"url": "https://trello.com/c/abc/1-x", "isUpload": False}, "card"),
|
||||
({"url": "https://trello.com/1/.../a.png", "isUpload": True, "mimeType": "image/jpeg"}, "image"),
|
||||
({"url": "https://trello.com/1/.../a.pdf", "isUpload": True, "mimeType": "application/pdf"}, "file"),
|
||||
({"url": "https://figma.com/file/xyz", "isUpload": False}, "link"),
|
||||
],
|
||||
)
|
||||
def test_attachment_kind(attachment, kind):
|
||||
assert trello._attachment_kind(attachment) == kind
|
||||
|
||||
|
||||
ATTACHMENT_META_URL = "https://api.trello.com/1/cards/card1/attachments/a1"
|
||||
DOWNLOAD_URL = "https://trello.com/1/cards/card1/attachments/a1/download/лог ошибки.txt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trello_creds(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TRELLO_API_KEY", "key123")
|
||||
monkeypatch.setenv("TRELLO_TOKEN", "tok456")
|
||||
monkeypatch.setenv("TRELLO_ATTACHMENTS_DIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_attachment_downloads_upload_with_oauth(trello_creds):
|
||||
meta = {
|
||||
"id": "a1",
|
||||
"name": "лог ошибки.txt",
|
||||
"url": DOWNLOAD_URL,
|
||||
"mimeType": "text/plain",
|
||||
"bytes": 5,
|
||||
"isUpload": True,
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get(ATTACHMENT_META_URL).mock(return_value=httpx.Response(200, json=meta))
|
||||
download = respx.get(DOWNLOAD_URL).mock(return_value=httpx.Response(200, content=b"oops!"))
|
||||
result = await trello.get_attachment("card1", "a1")
|
||||
|
||||
assert download.calls.last.request.headers["Authorization"] == (
|
||||
'OAuth oauth_consumer_key="key123", oauth_token="tok456"'
|
||||
)
|
||||
assert result["data"] == b"oops!"
|
||||
assert Path(result["path"]).read_bytes() == b"oops!"
|
||||
assert Path(result["path"]).parent == trello_creds / "card1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_attachment_link_not_downloaded(trello_creds):
|
||||
meta = {"id": "a1", "name": "Макет", "url": "https://figma.com/file/x", "isUpload": False}
|
||||
with respx.mock:
|
||||
respx.get(ATTACHMENT_META_URL).mock(return_value=httpx.Response(200, json=meta))
|
||||
result = await trello.get_attachment("card1", "a1")
|
||||
|
||||
assert result["kind"] == "link"
|
||||
assert "data" not in result and "path" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_attachment_refuses_non_trello_upload_host(trello_creds):
|
||||
meta = {
|
||||
"id": "a1",
|
||||
"name": "x.pdf",
|
||||
"url": "https://evil.example.com/x.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"bytes": 5,
|
||||
"isUpload": True,
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get(ATTACHMENT_META_URL).mock(return_value=httpx.Response(200, json=meta))
|
||||
with pytest.raises(ValueError, match="не на Trello"):
|
||||
await trello.get_attachment("card1", "a1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_attachment_too_big(trello_creds):
|
||||
meta = {
|
||||
"id": "a1",
|
||||
"name": "video.mp4",
|
||||
"url": DOWNLOAD_URL,
|
||||
"mimeType": "video/mp4",
|
||||
"bytes": trello.MAX_ATTACHMENT_BYTES + 1,
|
||||
"isUpload": True,
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get(ATTACHMENT_META_URL).mock(return_value=httpx.Response(200, json=meta))
|
||||
download = respx.get(DOWNLOAD_URL)
|
||||
with pytest.raises(ValueError, match="больше"):
|
||||
await trello.get_attachment("card1", "a1")
|
||||
|
||||
assert not download.called
|
||||
Reference in New Issue
Block a user