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:
2026-09-24 14:14:29 +00:00
commit b4cabbf146
14 changed files with 2100 additions and 0 deletions
+283
View File
@@ -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
+78
View File
@@ -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"}
+144
View File
@@ -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"}
+266
View File
@@ -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