diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 3f6bc3d..0e2cd56 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -51,6 +51,17 @@ export type CliContext = { resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType; loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[]; printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void; + printTweetsResult: ( + result: { + tweets?: TweetData[]; + nextCursor?: string; + }, + opts: { + json: boolean; + usePagination: boolean; + emptyMessage: string; + }, + ) => void; extractTweetId: (tweetIdOrUrl: string) => string; }; @@ -389,6 +400,18 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn } } + function printTweetsResult( + result: { tweets?: TweetData[]; nextCursor?: string }, + opts: { json: boolean; usePagination: boolean; emptyMessage: string }, + ) { + const tweets = result.tweets ?? []; + if (opts.json && opts.usePagination) { + console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2)); + return; + } + printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage }); + } + return { isTty, getOutput: () => output, @@ -402,6 +425,7 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn resolveCredentialsFromOptions, loadMedia, printTweets, + printTweetsResult, extractTweetId, }; } diff --git a/src/commands/bookmarks.ts b/src/commands/bookmarks.ts index ab55e27..89a8ed3 100644 --- a/src/commands/bookmarks.ts +++ b/src/commands/bookmarks.ts @@ -74,11 +74,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi if (result.success && result.tweets) { const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; const isJson = cmdOpts.json || cmdOpts.jsonFull; - if (isJson && usePagination) { - console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2)); - } else { - ctx.printTweets(result.tweets, { json: isJson, emptyMessage }); - } + ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage }); } else { console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`); process.exit(1); diff --git a/src/commands/lists.ts b/src/commands/lists.ts index 3fe7d45..8aef7d9 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -139,11 +139,11 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { if (result.success && result.tweets) { const isJson = cmdOpts.json || cmdOpts.jsonFull; - if (isJson && usePagination) { - console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2)); - } else { - ctx.printTweets(result.tweets, { json: isJson, emptyMessage: 'No tweets found in this list.' }); - } + ctx.printTweetsResult(result, { + json: isJson, + usePagination, + emptyMessage: 'No tweets found in this list.', + }); } else { console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`); process.exit(1); diff --git a/src/commands/user-tweets.ts b/src/commands/user-tweets.ts index 72906f8..52aa6d7 100644 --- a/src/commands/user-tweets.ts +++ b/src/commands/user-tweets.ts @@ -115,15 +115,11 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo if (result.success && result.tweets) { const isJson = cmdOpts.json || cmdOpts.jsonFull; - if (isJson && wantsPaginationOutput) { - // Include nextCursor for pagination consumers - console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2)); - } else { - ctx.printTweets(result.tweets, { - json: isJson, - emptyMessage: `No tweets found for @${username}.`, - }); - } + ctx.printTweetsResult(result, { + json: isJson, + usePagination: wantsPaginationOutput, + emptyMessage: `No tweets found for @${username}.`, + }); // Show pagination hint if there's more if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) { diff --git a/src/lib/twitter-client-user-lookup.ts b/src/lib/twitter-client-user-lookup.ts new file mode 100644 index 0000000..b4d6061 --- /dev/null +++ b/src/lib/twitter-client-user-lookup.ts @@ -0,0 +1,211 @@ +import { normalizeHandle } from './normalize-handle.js'; +import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; +import { TWITTER_API_BASE } from './twitter-client-constants.js'; + +/** Result of username to userId lookup */ +export interface UserLookupResult { + success: boolean; + userId?: string; + username?: string; + name?: string; + error?: string; +} + +export interface TwitterClientUserLookupMethods { + getUserIdByUsername(username: string): Promise; +} + +export function withUserLookup>( + Base: TBase, +): Mixin { + abstract class TwitterClientUserLookup extends Base { + // biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement. + constructor(...args: any[]) { + super(...args); + } + + private async getUserByScreenNameGraphQL(screenName: string): Promise { + // UserByScreenName query IDs observed from web client + const queryIds = ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w']; + + const variables = { + screen_name: screenName, + withSafetyModeUserFields: true, + }; + + const features = { + hidden_profile_subscriptions_enabled: true, + hidden_profile_likes_enabled: true, + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + subscriptions_verification_info_is_identity_verified_enabled: true, + subscriptions_verification_info_verified_since_enabled: true, + highlights_tweets_tab_ui_enabled: true, + responsive_web_twitter_article_notes_tab_enabled: true, + subscriptions_feature_can_gift_premium: true, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + blue_business_profile_image_shape_enabled: true, + }; + + const fieldToggles = { + withAuxiliaryUserLabels: false, + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + fieldToggles: JSON.stringify(fieldToggles), + }); + + let lastError: string | undefined; + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 404) { + // Try next query ID + lastError = `HTTP ${response.status}`; + continue; + } + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + __typename?: string; + rest_id?: string; + legacy?: { + screen_name?: string; + name?: string; + }; + core?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + // Check for user not found + if (data.data?.user?.result?.__typename === 'UserUnavailable') { + return { success: false, error: `User @${screenName} not found or unavailable` }; + } + + const userResult = data.data?.user?.result; + const userId = userResult?.rest_id; + const username = userResult?.legacy?.screen_name ?? userResult?.core?.screen_name; + const name = userResult?.legacy?.name ?? userResult?.core?.name; + + if (userId && username) { + return { + success: true, + userId, + username, + name, + }; + } + + if (data.errors && data.errors.length > 0) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } + + lastError = 'Could not parse user data from response'; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false, error: lastError ?? 'Unknown error looking up user' }; + } + + /** + * Look up a user's ID by their username/handle. + * Uses GraphQL UserByScreenName first, then falls back to REST on transient failures. + */ + async getUserIdByUsername(username: string): Promise { + const cleanUsername = normalizeHandle(username); + if (!cleanUsername) { + return { success: false, error: `Invalid username: ${username}` }; + } + + const graphqlResult = await this.getUserByScreenNameGraphQL(cleanUsername); + if (graphqlResult.success) { + return graphqlResult; + } + + // If GraphQL definitively says user is unavailable, don't retry with REST + if (graphqlResult.error?.includes('not found or unavailable')) { + return graphqlResult; + } + + // Fallback to REST API for transient GraphQL errors + const urls = [ + `https://x.com/i/api/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, + `https://api.twitter.com/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, + ]; + + let lastError: string | undefined = graphqlResult.error; + + for (const url of urls) { + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 404) { + return { success: false, error: `User @${cleanUsername} not found` }; + } + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + const data = (await response.json()) as { + id_str?: string; + id?: number; + screen_name?: string; + name?: string; + }; + + const userId = data.id_str ?? (data.id ? String(data.id) : null); + if (!userId) { + lastError = 'Could not parse user ID from response'; + continue; + } + + return { + success: true, + userId, + username: data.screen_name ?? cleanUsername, + name: data.name, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false, error: lastError ?? 'Unknown error looking up user' }; + } + } + + return TwitterClientUserLookup; +} diff --git a/src/lib/twitter-client-user-tweets.ts b/src/lib/twitter-client-user-tweets.ts index 453d5b4..42b2bcc 100644 --- a/src/lib/twitter-client-user-tweets.ts +++ b/src/lib/twitter-client-user-tweets.ts @@ -1,4 +1,3 @@ -import { normalizeHandle } from './normalize-handle.js'; import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { buildUserTweetsFeatures } from './twitter-client-features.js'; @@ -21,17 +20,7 @@ export interface UserTweetsPaginationOptions extends UserTweetsFetchOptions { pageDelayMs?: number; } -/** Result of username to userId lookup */ -export interface UserLookupResult { - success: boolean; - userId?: string; - username?: string; - name?: string; - error?: string; -} - export interface TwitterClientUserTweetsMethods { - getUserIdByUsername(username: string): Promise; getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise; getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise; } @@ -51,196 +40,10 @@ export function withUserTweets { - // UserByScreenName query IDs observed from web client - const queryIds = ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w']; - - const variables = { - screen_name: screenName, - withSafetyModeUserFields: true, - }; - - const features = { - hidden_profile_subscriptions_enabled: true, - hidden_profile_likes_enabled: true, - rweb_tipjar_consumption_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - verified_phone_label_enabled: false, - subscriptions_verification_info_is_identity_verified_enabled: true, - subscriptions_verification_info_verified_since_enabled: true, - highlights_tweets_tab_ui_enabled: true, - responsive_web_twitter_article_notes_tab_enabled: true, - subscriptions_feature_can_gift_premium: true, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - responsive_web_graphql_timeline_navigation_enabled: true, - blue_business_profile_image_shape_enabled: true, - }; - - const fieldToggles = { - withAuxiliaryUserLabels: false, - }; - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - fieldToggles: JSON.stringify(fieldToggles), - }); - - let lastError: string | undefined; - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (!response.ok) { - const text = await response.text(); - if (response.status === 404) { - // Try next query ID - lastError = `HTTP ${response.status}`; - continue; - } - lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; - continue; - } - - const data = (await response.json()) as { - data?: { - user?: { - result?: { - __typename?: string; - rest_id?: string; - id?: string; - legacy?: { - screen_name?: string; - name?: string; - }; - core?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - // Check for user not found - if (data.data?.user?.result?.__typename === 'UserUnavailable') { - return { success: false, error: `User @${screenName} not found or unavailable` }; - } - - const userResult = data.data?.user?.result; - const userId = userResult?.rest_id; - const username = userResult?.legacy?.screen_name ?? userResult?.core?.screen_name; - const name = userResult?.legacy?.name ?? userResult?.core?.name; - - if (userId && username) { - return { - success: true, - userId, - username, - name, - }; - } - - if (data.errors && data.errors.length > 0) { - lastError = data.errors.map((e) => e.message).join(', '); - continue; - } - - lastError = 'Could not parse user data from response'; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false, error: lastError ?? 'Unknown error looking up user' }; - } - private async sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } - /** - * Look up a user's ID by their username/handle. - * Uses GraphQL UserByScreenName first, then falls back to REST on transient failures. - */ - async getUserIdByUsername(username: string): Promise { - // Normalize and validate handle - const cleanUsername = normalizeHandle(username); - if (!cleanUsername) { - return { success: false, error: `Invalid username: ${username}` }; - } - - // Try GraphQL UserByScreenName first (more reliable with cookie auth) - const graphqlResult = await this.getUserByScreenNameGraphQL(cleanUsername); - if (graphqlResult.success) { - return graphqlResult; - } - - // If GraphQL definitively says user is unavailable, don't retry with REST - if (graphqlResult.error?.includes('not found or unavailable')) { - return graphqlResult; - } - - // Fallback to REST API for transient GraphQL errors - const urls = [ - `https://x.com/i/api/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, - `https://api.twitter.com/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, - ]; - - let lastError: string | undefined = graphqlResult.error; - - for (const url of urls) { - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (!response.ok) { - const text = await response.text(); - // Check for user not found - if (response.status === 404) { - return { success: false, error: `User @${cleanUsername} not found` }; - } - lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; - continue; - } - - const data = (await response.json()) as { - id_str?: string; - id?: number; - screen_name?: string; - name?: string; - }; - - const userId = data.id_str ?? (data.id ? String(data.id) : null); - if (!userId) { - lastError = 'Could not parse user ID from response'; - continue; - } - - return { - success: true, - userId, - username: data.screen_name ?? cleanUsername, - name: data.name, - }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false, error: lastError ?? 'Unknown error looking up user' }; - } - /** * Get tweets from a user's profile timeline (single page). */ diff --git a/src/lib/twitter-client-users.ts b/src/lib/twitter-client-users.ts index 3736195..b95ee42 100644 --- a/src/lib/twitter-client-users.ts +++ b/src/lib/twitter-client-users.ts @@ -6,9 +6,22 @@ import { TWITTER_API_BASE, } from './twitter-client-constants.js'; import { buildFollowingFeatures } from './twitter-client-features.js'; -import type { CurrentUserResult, FollowingResult } from './twitter-client-types.js'; +import type { CurrentUserResult, FollowingResult, TwitterUser } from './twitter-client-types.js'; import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js'; +type RestUser = { + id_str?: string; + id?: string | number; + screen_name?: string; + name?: string; + description?: string; + followers_count?: number; + friends_count?: number; + verified?: boolean; + profile_image_url_https?: string; + created_at?: string; +}; + export interface TwitterClientUserMethods { getCurrentUser(): Promise; getFollowing(userId: string, count?: number, cursor?: string): Promise; @@ -34,6 +47,29 @@ export function withUsers>( return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w'])); } + private parseUsersFromRestResponse(users: RestUser[] | undefined): TwitterUser[] { + return (users ?? []) + .map((u) => { + const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null; + const username = typeof u.screen_name === 'string' ? u.screen_name : null; + if (!id || !username) { + return null; + } + return { + id, + username, + name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username, + description: typeof u.description === 'string' ? u.description : undefined, + followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined, + followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined, + isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined, + profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined, + createdAt: typeof u.created_at === 'string' ? u.created_at : undefined, + }; + }) + .filter((u) => u !== null); + } + private async getFollowersViaRest(userId: string, count: number): Promise { const params = new URLSearchParams({ user_id: userId, @@ -63,40 +99,10 @@ export function withUsers>( } const data = (await response.json()) as { - users?: Array<{ - id_str?: string; - id?: string | number; - screen_name?: string; - name?: string; - description?: string; - followers_count?: number; - friends_count?: number; - verified?: boolean; - profile_image_url_https?: string; - created_at?: string; - }>; + users?: RestUser[]; }; - const users = (data.users ?? []) - .map((u) => { - const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null; - const username = typeof u.screen_name === 'string' ? u.screen_name : null; - if (!id || !username) { - return null; - } - return { - id, - username, - name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username, - description: typeof u.description === 'string' ? u.description : undefined, - followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined, - followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined, - isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined, - profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined, - createdAt: typeof u.created_at === 'string' ? u.created_at : undefined, - }; - }) - .filter((u) => u !== null); + const users = this.parseUsersFromRestResponse(data.users); return { success: true, users }; } catch (error) { @@ -136,40 +142,10 @@ export function withUsers>( } const data = (await response.json()) as { - users?: Array<{ - id_str?: string; - id?: string | number; - screen_name?: string; - name?: string; - description?: string; - followers_count?: number; - friends_count?: number; - verified?: boolean; - profile_image_url_https?: string; - created_at?: string; - }>; + users?: RestUser[]; }; - const users = (data.users ?? []) - .map((u) => { - const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null; - const username = typeof u.screen_name === 'string' ? u.screen_name : null; - if (!id || !username) { - return null; - } - return { - id, - username, - name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username, - description: typeof u.description === 'string' ? u.description : undefined, - followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined, - followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined, - isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined, - profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined, - createdAt: typeof u.created_at === 'string' ? u.created_at : undefined, - }; - }) - .filter((u) => u !== null); + const users = this.parseUsersFromRestResponse(data.users); return { success: true, users }; } catch (error) { diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index f4c5838..4924097 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -8,6 +8,7 @@ import { type TwitterClientPostingMethods, withPosting } from './twitter-client- import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js'; import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.js'; import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitter-client-tweet-detail.js'; +import { type TwitterClientUserLookupMethods, withUserLookup } from './twitter-client-user-lookup.js'; import { type TwitterClientUserTweetsMethods, withUserTweets } from './twitter-client-user-tweets.js'; import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js'; @@ -21,12 +22,15 @@ type TwitterClientInstance = TwitterClientBase & TwitterClientTimelineMethods & TwitterClientTweetDetailMethods & TwitterClientUserMethods & + TwitterClientUserLookupMethods & TwitterClientUserTweetsMethods; const MixedTwitterClient = withUserTweets( - withUsers( - withLists( - withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), + withUserLookup( + withUsers( + withLists( + withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), + ), ), ), ) as AbstractConstructor; diff --git a/tests/live/live.test.ts b/tests/live/live.test.ts index ca46a6f..5aa1a9c 100644 --- a/tests/live/live.test.ts +++ b/tests/live/live.test.ts @@ -281,7 +281,8 @@ d('live CLI (Twitter/X)', () => { const tweets = parseJson>(userTweets.stdout); expect(Array.isArray(tweets)).toBe(true); expect(tweets.length).toBeGreaterThan(0); - expect(tweets[0].author?.username?.toLowerCase()).toBe(testHandle.toLowerCase()); + const handleLower = testHandle.toLowerCase(); + expect(tweets.some((t) => t.author?.username?.toLowerCase() === handleLower)).toBe(true); }); it('user-tweets paged JSON returns { tweets, nextCursor }', async () => { diff --git a/tests/twitter-client.user-lookup.test.ts b/tests/twitter-client.user-lookup.test.ts new file mode 100644 index 0000000..06683bf --- /dev/null +++ b/tests/twitter-client.user-lookup.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; +import { validCookies } from './twitter-client-fixtures.js'; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('TwitterClient getUserIdByUsername', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('resolves username to userId via GraphQL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'User', + rest_id: '12345', + legacy: { + screen_name: 'testuser', + name: 'Test User', + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getUserIdByUsername('@testuser'); + + expect(result.success).toBe(true); + expect(result.userId).toBe('12345'); + expect(result.username).toBe('testuser'); + expect(result.name).toBe('Test User'); + }); + + it('handles username without @ prefix', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'User', + rest_id: '67890', + legacy: { + screen_name: 'anotheruser', + name: 'Another User', + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getUserIdByUsername('anotheruser'); + + expect(result.success).toBe(true); + expect(result.userId).toBe('67890'); + }); + + it('returns error for invalid username format', async () => { + const client = new TwitterClient({ cookies: validCookies }); + + const result = await client.getUserIdByUsername('invalid handle with spaces'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid username'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns error for empty username', async () => { + const client = new TwitterClient({ cookies: validCookies }); + + const result = await client.getUserIdByUsername(''); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid username'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns error for username exceeding 15 characters', async () => { + const client = new TwitterClient({ cookies: validCookies }); + + const result = await client.getUserIdByUsername('thisusernameiswaytoolong'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid username'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns error when user is unavailable', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'UserUnavailable', + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getUserIdByUsername('suspended'); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found or unavailable'); + }); + + it('retries with fallback query IDs on 404', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not found', + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not found', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'User', + rest_id: '99999', + legacy: { + screen_name: 'founduser', + name: 'Found User', + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getUserIdByUsername('founduser'); + + expect(result.success).toBe(true); + expect(result.userId).toBe('99999'); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/twitter-client.user-tweets.test.ts b/tests/twitter-client.user-tweets.test.ts index a31ef15..57524c1 100644 --- a/tests/twitter-client.user-tweets.test.ts +++ b/tests/twitter-client.user-tweets.test.ts @@ -9,162 +9,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('TwitterClient getUserIdByUsername', () => { - let mockFetch: ReturnType; - - beforeEach(() => { - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - }); - - it('resolves username to userId via GraphQL', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - __typename: 'User', - rest_id: '12345', - legacy: { - screen_name: 'testuser', - name: 'Test User', - }, - }, - }, - }, - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserIdByUsername('@testuser'); - - expect(result.success).toBe(true); - expect(result.userId).toBe('12345'); - expect(result.username).toBe('testuser'); - expect(result.name).toBe('Test User'); - }); - - it('handles username without @ prefix', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - __typename: 'User', - rest_id: '67890', - legacy: { - screen_name: 'anotheruser', - name: 'Another User', - }, - }, - }, - }, - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserIdByUsername('anotheruser'); - - expect(result.success).toBe(true); - expect(result.userId).toBe('67890'); - }); - - it('returns error for invalid username format', async () => { - const client = new TwitterClient({ cookies: validCookies }); - - const result = await client.getUserIdByUsername('invalid handle with spaces'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid username'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('returns error for empty username', async () => { - const client = new TwitterClient({ cookies: validCookies }); - - const result = await client.getUserIdByUsername(''); - - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid username'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('returns error for username exceeding 15 characters', async () => { - const client = new TwitterClient({ cookies: validCookies }); - - const result = await client.getUserIdByUsername('thisusernameiswaytoolong'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid username'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('returns error when user is unavailable', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - __typename: 'UserUnavailable', - }, - }, - }, - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserIdByUsername('suspended'); - - expect(result.success).toBe(false); - expect(result.error).toContain('not found or unavailable'); - }); - - it('retries with fallback query IDs on 404', async () => { - mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not found', - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not found', - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - __typename: 'User', - rest_id: '99999', - legacy: { - screen_name: 'founduser', - name: 'Found User', - }, - }, - }, - }, - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserIdByUsername('founduser'); - - expect(result.success).toBe(true); - expect(result.userId).toBe('99999'); - expect(mockFetch).toHaveBeenCalledTimes(3); - }); -}); - describe('TwitterClient getUserTweets', () => { let mockFetch: ReturnType;