|
9 | 9 |
|
10 | 10 | import pytest |
11 | 11 |
|
12 | | -from xclient import XTimelineClient, expand_tco_urls |
| 12 | +from xclient import XTimelineClient, _collapse_curl, _extract_cookies_from_curl, expand_tco_urls |
13 | 13 |
|
14 | 14 | # --------------------------------------------------------------------------- |
15 | 15 | # Helpers |
@@ -68,10 +68,19 @@ def _wrap_user(name: str, screen_name: str, image_url: str = "") -> dict: |
68 | 68 | "result": { |
69 | 69 | "__typename": "Tweet", |
70 | 70 | "rest_id": "2037910161172377806", |
71 | | - "core": _wrap_user("TraderSZ", "trader1sz"), |
| 71 | + "core": _wrap_user( |
| 72 | + "TraderSZ", |
| 73 | + "trader1sz", |
| 74 | + "https://pbs.twimg.com/profile_images/tradersz_normal.jpg", |
| 75 | + ), |
| 76 | + "views": {"count": "5000", "state": "Enabled"}, |
72 | 77 | "legacy": { |
73 | 78 | "id_str": "2037910161172377806", |
74 | 79 | "full_text": "$ETHBTC doesnt look too bad here", |
| 80 | + "created_at": "Tue Apr 01 12:00:00 +0000 2026", |
| 81 | + "favorite_count": 50, |
| 82 | + "retweet_count": 10, |
| 83 | + "reply_count": 3, |
75 | 84 | "entities": { |
76 | 85 | "symbols": [{"text": "ETHBTC", "indices": [0, 7]}], |
77 | 86 | "hashtags": [], |
@@ -597,3 +606,221 @@ def test_new_only_mode_skips_seen_tweets(self, client): |
597 | 606 |
|
598 | 607 | assert {t.id for t in results} == {1002} |
599 | 608 | assert client._last_tweet_id == 1002 |
| 609 | + |
| 610 | + |
| 611 | +# --------------------------------------------------------------------------- |
| 612 | +# Quoted tweet field |
| 613 | +# --------------------------------------------------------------------------- |
| 614 | + |
| 615 | +class TestQuotedTweetField: |
| 616 | + def test_quoted_tweet_is_populated(self, client): |
| 617 | + t = _parse(client, QUOTE_TWEET) |
| 618 | + assert t.quoted_tweet is not None |
| 619 | + |
| 620 | + def test_quoted_tweet_user_name(self, client): |
| 621 | + t = _parse(client, QUOTE_TWEET) |
| 622 | + assert t.quoted_tweet.user_name == "TraderSZ" |
| 623 | + |
| 624 | + def test_quoted_tweet_user_screen_name(self, client): |
| 625 | + t = _parse(client, QUOTE_TWEET) |
| 626 | + assert t.quoted_tweet.user_screen_name == "trader1sz" |
| 627 | + |
| 628 | + def test_quoted_tweet_user_img(self, client): |
| 629 | + t = _parse(client, QUOTE_TWEET) |
| 630 | + assert t.quoted_tweet.user_img == "https://pbs.twimg.com/profile_images/tradersz_normal.jpg" |
| 631 | + |
| 632 | + def test_quoted_tweet_id(self, client): |
| 633 | + t = _parse(client, QUOTE_TWEET) |
| 634 | + assert t.quoted_tweet.id == 2037910161172377806 |
| 635 | + |
| 636 | + def test_quoted_tweet_url(self, client): |
| 637 | + t = _parse(client, QUOTE_TWEET) |
| 638 | + assert t.quoted_tweet.url == "https://twitter.com/user/status/2037910161172377806" |
| 639 | + |
| 640 | + def test_quoted_tweet_text(self, client): |
| 641 | + t = _parse(client, QUOTE_TWEET) |
| 642 | + assert t.quoted_tweet.text == "$ETHBTC doesnt look too bad here" |
| 643 | + |
| 644 | + def test_quoted_tweet_created_at(self, client): |
| 645 | + t = _parse(client, QUOTE_TWEET) |
| 646 | + assert t.quoted_tweet.created_at == "2026-04-01T12:00:00Z" |
| 647 | + |
| 648 | + def test_quoted_tweet_likes(self, client): |
| 649 | + t = _parse(client, QUOTE_TWEET) |
| 650 | + assert t.quoted_tweet.likes == 50 |
| 651 | + |
| 652 | + def test_quoted_tweet_retweets(self, client): |
| 653 | + t = _parse(client, QUOTE_TWEET) |
| 654 | + assert t.quoted_tweet.retweets == 10 |
| 655 | + |
| 656 | + def test_quoted_tweet_replies(self, client): |
| 657 | + t = _parse(client, QUOTE_TWEET) |
| 658 | + assert t.quoted_tweet.replies == 3 |
| 659 | + |
| 660 | + def test_quoted_tweet_views(self, client): |
| 661 | + t = _parse(client, QUOTE_TWEET) |
| 662 | + assert t.quoted_tweet.views == 5000 |
| 663 | + |
| 664 | + def test_plain_tweet_has_no_quoted_tweet(self, client): |
| 665 | + t = _parse(client, PLAIN_TWEET) |
| 666 | + assert t.quoted_tweet is None |
| 667 | + |
| 668 | + def test_retweet_has_no_quoted_tweet(self, client): |
| 669 | + t = _parse(client, RETWEET) |
| 670 | + assert t.quoted_tweet is None |
| 671 | + |
| 672 | + def test_quoted_tweet_serializes_in_to_dict(self, client): |
| 673 | + t = _parse(client, QUOTE_TWEET) |
| 674 | + d = t.to_dict() |
| 675 | + assert isinstance(d["quoted_tweet"], dict) |
| 676 | + assert d["quoted_tweet"]["user_screen_name"] == "trader1sz" |
| 677 | + |
| 678 | + def test_plain_tweet_quoted_tweet_is_none_in_to_dict(self, client): |
| 679 | + t = _parse(client, PLAIN_TWEET) |
| 680 | + d = t.to_dict() |
| 681 | + assert d["quoted_tweet"] is None |
| 682 | + |
| 683 | + |
| 684 | +# --------------------------------------------------------------------------- |
| 685 | +# TweetWithVisibilityResults (restricted reply / visibility) |
| 686 | +# --------------------------------------------------------------------------- |
| 687 | + |
| 688 | +VISIBILITY_TWEET: dict = { |
| 689 | + "__typename": "TweetWithVisibilityResults", |
| 690 | + "limitedActionResults": { |
| 691 | + "limited_actions": [ |
| 692 | + { |
| 693 | + "action": "Reply", |
| 694 | + "prompt": { |
| 695 | + "__typename": "CtaLimitedActionPrompt", |
| 696 | + "cta_type": "SeeConversation", |
| 697 | + "headline": {"entities": [], "text": "Who can reply?"}, |
| 698 | + "subtext": {"entities": [], "text": "Only some accounts can reply."}, |
| 699 | + }, |
| 700 | + } |
| 701 | + ] |
| 702 | + }, |
| 703 | + "tweet": { |
| 704 | + "__typename": "Tweet", |
| 705 | + "rest_id": "2039500000000000001", |
| 706 | + "core": _wrap_user( |
| 707 | + "Crypto Mikey", |
| 708 | + "CryptoCX1", |
| 709 | + "https://pbs.twimg.com/profile_images/mikey_normal.jpg", |
| 710 | + ), |
| 711 | + "views": {"count": "12000", "state": "Enabled"}, |
| 712 | + "legacy": { |
| 713 | + "id_str": "2039500000000000001", |
| 714 | + "full_text": "$SPX big test coming up.", |
| 715 | + "created_at": "Tue Apr 01 15:00:00 +0000 2026", |
| 716 | + "favorite_count": 80, |
| 717 | + "retweet_count": 20, |
| 718 | + "reply_count": 5, |
| 719 | + "entities": { |
| 720 | + "symbols": [{"text": "SPX", "indices": [0, 4]}], |
| 721 | + "hashtags": [], |
| 722 | + }, |
| 723 | + }, |
| 724 | + }, |
| 725 | +} |
| 726 | + |
| 727 | + |
| 728 | +class TestVisibilityTweet: |
| 729 | + """TweetWithVisibilityResults — restricted replies, tweet itself is visible.""" |
| 730 | + |
| 731 | + def _parse_wrapped(self, client): |
| 732 | + """Simulate how the entry parser calls normalize_tweet_result.""" |
| 733 | + from xclient import normalize_tweet_result |
| 734 | + tw = normalize_tweet_result({"result": VISIBILITY_TWEET}) |
| 735 | + return client._parse_single_tweet(tw) |
| 736 | + |
| 737 | + def test_parsed_successfully(self, client): |
| 738 | + t = self._parse_wrapped(client) |
| 739 | + assert t is not None |
| 740 | + |
| 741 | + def test_id(self, client): |
| 742 | + t = self._parse_wrapped(client) |
| 743 | + assert t.id == 2039500000000000001 |
| 744 | + |
| 745 | + def test_user_name(self, client): |
| 746 | + t = self._parse_wrapped(client) |
| 747 | + assert t.user_name == "Crypto Mikey" |
| 748 | + |
| 749 | + def test_user_screen_name(self, client): |
| 750 | + t = self._parse_wrapped(client) |
| 751 | + assert t.user_screen_name == "CryptoCX1" |
| 752 | + |
| 753 | + def test_text(self, client): |
| 754 | + t = self._parse_wrapped(client) |
| 755 | + assert "$SPX" in t.text |
| 756 | + |
| 757 | + def test_likes(self, client): |
| 758 | + t = self._parse_wrapped(client) |
| 759 | + assert t.likes == 80 |
| 760 | + |
| 761 | + def test_views(self, client): |
| 762 | + t = self._parse_wrapped(client) |
| 763 | + assert t.views == 12000 |
| 764 | + |
| 765 | + def test_ticker_extracted(self, client): |
| 766 | + t = self._parse_wrapped(client) |
| 767 | + assert "SPX" in t.tickers |
| 768 | + |
| 769 | + |
| 770 | +# --------------------------------------------------------------------------- |
| 771 | +# Cookie extraction — Chrome and Firefox curl formats |
| 772 | +# --------------------------------------------------------------------------- |
| 773 | + |
| 774 | +_CHROME_CURL = ( |
| 775 | + "curl 'https://example.com' " |
| 776 | + "-b 'auth_token=abc123; ct0=def456; __cuid=xyz'" |
| 777 | +) |
| 778 | + |
| 779 | +_FIREFOX_CURL = ( |
| 780 | + "curl 'https://example.com' \\\n" |
| 781 | + " -H 'Authorization: Bearer token' \\\n" |
| 782 | + " -H 'Cookie: auth_token=abc123; ct0=def456; __cuid=xyz' \\\n" |
| 783 | + " --data-raw '{}'" |
| 784 | +) |
| 785 | + |
| 786 | +_FIREFOX_CURL_LONG = ( |
| 787 | + # Simulates a long Cookie header that wraps across a line boundary when |
| 788 | + # the browser copies it; the backslash continuation is mid-value. |
| 789 | + "curl 'https://example.com' \\\n" |
| 790 | + " -H 'Authorization: Bearer token' \\\n" |
| 791 | + " -H 'Cookie: auth_token=abc123; ct0=def456; __cuid=xyz; extra=val' \\\n" |
| 792 | + " --data-raw '{}'" |
| 793 | +) |
| 794 | + |
| 795 | + |
| 796 | +class TestCookieParsing: |
| 797 | + def test_chrome_format_extracts_auth_token(self): |
| 798 | + cookies = _extract_cookies_from_curl(_CHROME_CURL) |
| 799 | + assert cookies["auth_token"] == "abc123" |
| 800 | + |
| 801 | + def test_chrome_format_extracts_ct0(self): |
| 802 | + cookies = _extract_cookies_from_curl(_CHROME_CURL) |
| 803 | + assert cookies["ct0"] == "def456" |
| 804 | + |
| 805 | + def test_firefox_format_extracts_auth_token(self): |
| 806 | + cookies = _extract_cookies_from_curl(_FIREFOX_CURL) |
| 807 | + assert cookies["auth_token"] == "abc123" |
| 808 | + |
| 809 | + def test_firefox_format_extracts_ct0(self): |
| 810 | + cookies = _extract_cookies_from_curl(_FIREFOX_CURL) |
| 811 | + assert cookies["ct0"] == "def456" |
| 812 | + |
| 813 | + def test_firefox_format_extracts_cuid(self): |
| 814 | + cookies = _extract_cookies_from_curl(_FIREFOX_CURL) |
| 815 | + assert cookies["__cuid"] == "xyz" |
| 816 | + |
| 817 | + def test_multiline_firefox_curl_extracts_all_cookies(self): |
| 818 | + cookies = _extract_cookies_from_curl(_FIREFOX_CURL_LONG) |
| 819 | + assert cookies["auth_token"] == "abc123" |
| 820 | + assert cookies["ct0"] == "def456" |
| 821 | + assert cookies["extra"] == "val" |
| 822 | + |
| 823 | + def test_collapse_curl_joins_backslash_continuations(self): |
| 824 | + raw = "curl 'url' \\\n -H 'Foo: bar'" |
| 825 | + assert "\\\n" not in _collapse_curl(raw) |
| 826 | + assert "-H 'Foo: bar'" in _collapse_curl(raw) |
0 commit comments