tweets.test.js
1 import { describe, expect, it } from 'vitest'; 2 import { getRegistry } from '@jackwener/opencli/registry'; 3 import { __test__ } from './tweets.js'; 4 5 describe('twitter tweets helpers', () => { 6 it('registers is_retweet in the default columns', () => { 7 const cmd = getRegistry().get('twitter/tweets'); 8 expect(cmd?.columns).toEqual(['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls']); 9 }); 10 11 it('falls back when queryId contains unsafe characters', () => { 12 expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123'); 13 expect(__test__.sanitizeQueryId('bad"id', 'fallback')).toBe('fallback'); 14 expect(__test__.sanitizeQueryId('bad/id', 'fallback')).toBe('fallback'); 15 expect(__test__.sanitizeQueryId(null, 'fallback')).toBe('fallback'); 16 }); 17 18 it('builds UserTweets url with cursor and features', () => { 19 const url = __test__.buildUserTweetsUrl('query123', '42', 20, 'cursor-1'); 20 expect(url).toContain('/i/api/graphql/query123/UserTweets'); 21 const decoded = decodeURIComponent(url); 22 expect(decoded).toContain('"userId":"42"'); 23 expect(decoded).toContain('"count":20'); 24 expect(decoded).toContain('"cursor":"cursor-1"'); 25 expect(decoded).toContain('longform_notetweets_consumption_enabled'); 26 }); 27 28 it('builds UserByScreenName url for the given handle', () => { 29 const url = __test__.buildUserByScreenNameUrl('uquery', 'jakevin7'); 30 expect(url).toContain('/i/api/graphql/uquery/UserByScreenName'); 31 expect(decodeURIComponent(url)).toContain('"screen_name":"jakevin7"'); 32 }); 33 34 it('prefers note_tweet text over legacy.full_text for long posts', () => { 35 const seen = new Set(); 36 const tweet = __test__.extractTweet({ 37 rest_id: '99', 38 legacy: { full_text: 'short truncated…', favorite_count: 1, retweet_count: 0, reply_count: 0, created_at: 'now' }, 39 note_tweet: { note_tweet_results: { result: { text: 'full long-form body' } } }, 40 core: { user_results: { result: { legacy: { screen_name: 'bob', name: 'Bob' } } } }, 41 views: { count: '42' }, 42 }, seen); 43 expect(tweet.text).toBe('full long-form body'); 44 expect(tweet.views).toBe(42); 45 }); 46 47 it('flags retweets via RT prefix or retweeted_status_result', () => { 48 const a = __test__.extractTweet({ 49 rest_id: '1', 50 legacy: { full_text: 'RT @foo: hi', favorite_count: 0, retweet_count: 0, reply_count: 0, created_at: '' }, 51 core: { user_results: { result: { legacy: { screen_name: 'u', name: 'U' } } } }, 52 }, new Set()); 53 expect(a.is_retweet).toBe(true); 54 55 const b = __test__.extractTweet({ 56 rest_id: '2', 57 legacy: { full_text: 'hello', favorite_count: 0, retweet_count: 0, reply_count: 0, created_at: '', retweeted_status_result: { result: {} } }, 58 core: { user_results: { result: { legacy: { screen_name: 'u', name: 'U' } } } }, 59 }, new Set()); 60 expect(b.is_retweet).toBe(true); 61 }); 62 63 it('parses chronological tweets and skips pinned instruction', () => { 64 const chronEntry = { 65 entryId: 'tweet-1', 66 content: { 67 itemContent: { 68 tweet_results: { 69 result: { 70 rest_id: '1', 71 legacy: { full_text: 'chronological post', favorite_count: 5, retweet_count: 1, reply_count: 2, created_at: 'now' }, 72 core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } }, 73 views: { count: '100' }, 74 }, 75 }, 76 }, 77 }, 78 }; 79 const cursorEntry = { 80 entryId: 'cursor-bottom-1', 81 content: { entryType: 'TimelineTimelineCursor', cursorType: 'Bottom', value: 'cursor-next' }, 82 }; 83 const pinnedEntry = { 84 entryId: 'tweet-pinned-999', 85 content: { 86 itemContent: { 87 tweet_results: { 88 result: { 89 rest_id: '999', 90 legacy: { full_text: 'pinned post', favorite_count: 0, retweet_count: 0, reply_count: 0, created_at: 'old' }, 91 core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } }, 92 }, 93 }, 94 }, 95 }, 96 }; 97 const payload = { 98 data: { 99 user: { 100 result: { 101 timeline_v2: { 102 timeline: { 103 instructions: [ 104 { type: 'TimelinePinEntry', entries: [pinnedEntry] }, 105 { entries: [chronEntry, cursorEntry] }, 106 ], 107 }, 108 }, 109 }, 110 }, 111 }, 112 }; 113 const result = __test__.parseUserTweets(payload, new Set()); 114 expect(result.nextCursor).toBe('cursor-next'); 115 expect(result.tweets).toHaveLength(1); 116 expect(result.tweets[0]).toMatchObject({ 117 id: '1', 118 author: 'alice', 119 text: 'chronological post', 120 likes: 5, 121 views: 100, 122 url: 'https://x.com/alice/status/1', 123 }); 124 }); 125 });