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,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