diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c6c58a..4a48306 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -213,6 +213,31 @@ def test_cli_user_error_yaml(monkeypatch) -> None: assert payload["error"]["code"] == "not_found" +def test_cli_tweet_reports_missing_focal_tweet_as_structured_error(monkeypatch) -> None: + from twitter_cli.exceptions import NotFoundError + + class FakeClient: + def fetch_tweet_detail(self, tweet_id: str, max_count: int): + raise NotFoundError( + "Tweet %s was not returned by the TweetDetail response" % tweet_id + ) + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) + monkeypatch.setattr( + "twitter_cli.cli.load_config", + lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}}, + ) + runner = CliRunner() + + result = runner.invoke(cli, ["tweet", "12345", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["ok"] is False + assert payload["error"]["code"] == "not_found" + assert "TweetDetail response" in payload["error"]["message"] + + def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None: class FakeClient: def fetch_tweet_detail(self, tweet_id: str, max_count: int): diff --git a/tests/test_client.py b/tests/test_client.py index c1393d3..1aba808 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,7 +16,7 @@ _best_chrome_target, TwitterClient, ) -from twitter_cli.exceptions import TwitterAPIError +from twitter_cli.exceptions import NotFoundError, TwitterAPIError from twitter_cli.graphql import ( FEATURES, FALLBACK_QUERY_IDS, @@ -332,6 +332,28 @@ def test_cookie_string_used_when_available( assert headers["sec-ch-ua-platform-version"] == '""' +class TestTweetDetailFetch: + def test_raises_when_focal_tweet_is_missing(self): + client = TwitterClient.__new__(TwitterClient) + client._fetch_timeline = MagicMock(return_value=[MagicMock(id="reply-1")]) + + with pytest.raises( + NotFoundError, + match="Tweet 123 was not returned by the TweetDetail response", + ): + client.fetch_tweet_detail("123", 20) + + def test_places_focal_tweet_before_replies(self): + client = TwitterClient.__new__(TwitterClient) + reply = MagicMock(id="reply-1") + focal_tweet = MagicMock(id="123") + client._fetch_timeline = MagicMock(return_value=[reply, focal_tweet]) + + tweets = client.fetch_tweet_detail("123", 20) + + assert tweets == [focal_tweet, reply] + + class TestPaginationBehavior: def test_fetch_timeline_can_include_promoted_content(self): client = TwitterClient.__new__(TwitterClient) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 0436c8e..e2dba56 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -373,7 +373,7 @@ def fetch_search(self, query, count=20, product="Top"): def fetch_tweet_detail(self, tweet_id, count=20): # type: (str, int) -> List[Tweet] """Fetch a tweet and its conversation thread (replies).""" - return self._fetch_timeline( + tweets = self._fetch_timeline( "TweetDetail", count, lambda data: _deep_get(data, "data", "tweetResult", "result", "timeline", "instructions") @@ -397,6 +397,12 @@ def fetch_tweet_detail(self, tweet_id, count=20): "withDisallowedReplyControls": False, }, ) + focal_tweet = next((tweet for tweet in tweets if tweet.id == tweet_id), None) + if focal_tweet is None: + raise NotFoundError( + "Tweet %s was not returned by the TweetDetail response" % tweet_id + ) + return [focal_tweet] + [tweet for tweet in tweets if tweet.id != tweet_id] def fetch_article(self, tweet_id): # type: (str) -> Tweet