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