feat: add image upload support for post/reply/quote commands

- Add upload_media() method to TwitterClient (INIT/APPEND/FINALIZE flow
  via upload.twitter.com, supports JPEG/PNG/GIF/WebP up to 5MB)
- Extend create_tweet() and quote_tweet() with optional media_ids param
- Add --image/-i option to post, reply, and quote CLI commands (max 4)
- Add MediaUploadError exception for upload-specific error handling
- Add 6 unit tests covering upload flow, validation, and media_ids

Co-Authored-By: Catafal <67582323+Catafal@users.noreply.github.com>
This commit is contained in:
jackwener
2026-03-13 01:57:27 +08:00
parent 7d1b519c85
commit 69cb85a1c2
5 changed files with 307 additions and 22 deletions

View File

@@ -9,6 +9,8 @@ from __future__ import annotations
import copy
from unittest.mock import MagicMock, patch
import pytest
from twitter_cli.client import (
FEATURES,
@@ -540,3 +542,144 @@ class TestParseUserResult:
assert user.following_count == 56
assert user.tweets_count == 78
assert user.likes_count == 0
# ── upload_media ─────────────────────────────────────────────────────────
class TestUploadMedia:
"""Tests for TwitterClient.upload_media()."""
def _make_client(self):
client = TwitterClient.__new__(TwitterClient)
client._auth_token = "tok"
client._ct0 = "ct0"
client._cookie_string = None
client._request_delay = 0
client._max_retries = 3
client._retry_base_delay = 5.0
client._max_count = 200
client._client_transaction = None
client._ct_init_attempted = True
return client
@patch("twitter_cli.client._get_cffi_session")
def test_upload_media_init_append_finalize(self, mock_session, tmp_path):
"""Happy path: INIT → APPEND → FINALIZE returns media_id."""
img = tmp_path / "photo.jpg"
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # fake JPEG
mock_resp_init = MagicMock()
mock_resp_init.status_code = 200
mock_resp_init.text = '{"media_id_string": "12345"}'
mock_resp_append = MagicMock()
mock_resp_append.status_code = 200
mock_resp_append.text = ""
mock_resp_finalize = MagicMock()
mock_resp_finalize.status_code = 200
mock_resp_finalize.text = '{"media_id_string": "12345"}'
sess = MagicMock()
sess.post = MagicMock(side_effect=[mock_resp_init, mock_resp_append, mock_resp_finalize])
mock_session.return_value = sess
client = self._make_client()
media_id = client.upload_media(str(img))
assert media_id == "12345"
assert sess.post.call_count == 3
def test_upload_media_file_not_found(self):
from twitter_cli.exceptions import MediaUploadError
client = self._make_client()
with pytest.raises(MediaUploadError, match="File not found"):
client.upload_media("/nonexistent/file.jpg")
def test_upload_media_too_large(self, tmp_path):
from twitter_cli.exceptions import MediaUploadError
img = tmp_path / "big.jpg"
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * (6 * 1024 * 1024)) # 6 MB
client = self._make_client()
with pytest.raises(MediaUploadError, match="File too large"):
client.upload_media(str(img))
def test_upload_media_unsupported_format(self, tmp_path):
from twitter_cli.exceptions import MediaUploadError
txt = tmp_path / "notes.txt"
txt.write_text("hello")
client = self._make_client()
with pytest.raises(MediaUploadError, match="Unsupported image format"):
client.upload_media(str(txt))
# ── create_tweet with media_ids ──────────────────────────────────────────
class TestCreateTweetWithMedia:
"""Tests that media_ids are correctly passed into CreateTweet variables."""
@patch("twitter_cli.client._get_cffi_session")
def test_create_tweet_with_media_ids(self, mock_session):
sess = MagicMock()
mock_session.return_value = sess
client = TwitterClient.__new__(TwitterClient)
client._auth_token = "tok"
client._ct0 = "ct0"
client._cookie_string = None
client._request_delay = 0
client._max_retries = 0
client._retry_base_delay = 0
client._max_count = 200
client._client_transaction = None
client._ct_init_attempted = True
captured_body = {}
def mock_graphql_post(operation_name, variables, features=None):
captured_body.update(variables)
return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "99"}}}}}
client._graphql_post = mock_graphql_post
result = client.create_tweet("test", media_ids=["111", "222"])
assert result == "99"
entities = captured_body["media"]["media_entities"]
assert len(entities) == 2
assert entities[0]["media_id"] == "111"
assert entities[1]["media_id"] == "222"
@patch("twitter_cli.client._get_cffi_session")
def test_create_tweet_without_media_ids(self, mock_session):
sess = MagicMock()
mock_session.return_value = sess
client = TwitterClient.__new__(TwitterClient)
client._auth_token = "tok"
client._ct0 = "ct0"
client._cookie_string = None
client._request_delay = 0
client._max_retries = 0
client._retry_base_delay = 0
client._max_count = 200
client._client_transaction = None
client._ct_init_attempted = True
captured_body = {}
def mock_graphql_post(operation_name, variables, features=None):
captured_body.update(variables)
return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "88"}}}}}
client._graphql_post = mock_graphql_post
result = client.create_tweet("no media")
assert result == "88"
assert captured_body["media"]["media_entities"] == []