diff --git a/CHANGELOG.md b/CHANGELOG.md index 267d35a..df4963b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. +- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern. - Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli. - Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv. diff --git a/src/commands/users.ts b/src/commands/users.ts index 62ce548..9c834cf 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -1,52 +1,121 @@ import type { Command } from 'commander'; import type { CliContext } from '../cli/shared.js'; import { TwitterClient } from '../lib/twitter-client.js'; +import type { TwitterUser } from '../lib/twitter-client-types.js'; export function registerUserCommands(program: Command, ctx: CliContext): void { program .command('following') .description('Get users that you (or another user) follow') .option('--user ', 'User ID to get following for (defaults to current user)') - .option('-n, --count ', 'Number of users to fetch', '20') + .option('-n, --count ', 'Number of users to fetch per page', '20') + .option('--cursor ', 'Cursor for pagination (from previous response)') + .option('--all', 'Fetch all users (paginate automatically)') + .option('--max-pages ', 'Stop after N pages when using --all') .option('--json', 'Output as JSON') - .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { - const opts = program.opts(); - const timeoutMs = ctx.resolveTimeoutFromOptions(opts); - const count = Number.parseInt(cmdOpts.count || '20', 10); + .action( + async (cmdOpts: { + user?: string; + count?: string; + cursor?: string; + all?: boolean; + maxPages?: string; + json?: boolean; + }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; - const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - - for (const warning of warnings) { - console.error(`${ctx.p('warn')}${warning}`); - } - - if (!cookies.authToken || !cookies.ct0) { - console.error(`${ctx.p('err')}Missing required credentials`); - process.exit(1); - } - - const client = new TwitterClient({ cookies, timeoutMs }); - - let userId = cmdOpts.user; - if (!userId) { - const currentUser = await client.getCurrentUser(); - if (!currentUser.success || !currentUser.user?.id) { - console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + const usePagination = cmdOpts.all || cmdOpts.cursor; + if (maxPages !== undefined && !cmdOpts.all) { + console.error(`${ctx.p('err')}--max-pages requires --all.`); + process.exit(1); + } + if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { + console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); process.exit(1); } - userId = currentUser.user.id; - } - const result = await client.getFollowing(userId, count); + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - if (result.success && result.users) { - if (cmdOpts.json) { - console.log(JSON.stringify(result.users, null, 2)); - } else { - if (result.users.length === 0) { - console.log('No users found.'); - } else { + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + + let userId = cmdOpts.user; + if (!userId) { + const currentUser = await client.getCurrentUser(); + if (!currentUser.success || !currentUser.user?.id) { + console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + process.exit(1); + } + userId = currentUser.user.id; + } + + if (cmdOpts.all) { + // Fetch all pages + const allUsers: TwitterUser[] = []; + const seen = new Set(); + let cursor: string | undefined = cmdOpts.cursor; + let pageNum = 0; + let nextCursor: string | undefined; + + while (true) { + pageNum++; + if (!cmdOpts.json) { + console.error(`${ctx.p('info')}Fetching page ${pageNum}...`); + } + + const result = await client.getFollowing(userId, count, cursor); + + if (!result.success || !result.users) { + console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`); + process.exit(1); + } + + let added = 0; for (const user of result.users) { + if (!seen.has(user.id)) { + seen.add(user.id); + allUsers.push(user); + added += 1; + } + } + + const pageCursor = result.nextCursor; + if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) { + nextCursor = undefined; + break; + } + + if (maxPages && pageNum >= maxPages) { + nextCursor = pageCursor; + break; + } + + cursor = pageCursor; + + // Rate limit: wait between pages to avoid overwhelming the API + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + if (cmdOpts.json) { + console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2)); + } else { + console.error(`${ctx.p('info')}Total: ${allUsers.length} users`); + if (nextCursor) { + console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`); + console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`); + } + for (const user of allUsers) { console.log(`@${user.username} (${user.name})`); if (user.description) { console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`); @@ -57,57 +126,156 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { console.log('──────────────────────────────────────────────────'); } } + } else { + // Single page fetch + const result = await client.getFollowing(userId, count, cmdOpts.cursor); + + if (result.success && result.users) { + if (cmdOpts.json) { + if (usePagination) { + console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2)); + } else { + console.log(JSON.stringify(result.users, null, 2)); + } + } else { + if (result.users.length === 0) { + console.log('No users found.'); + } else { + for (const user of result.users) { + console.log(`@${user.username} (${user.name})`); + if (user.description) { + console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`); + } + if (user.followersCount !== undefined) { + console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`); + } + console.log('──────────────────────────────────────────────────'); + } + if (result.nextCursor) { + console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`); + } + } + } + } else { + console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`); + process.exit(1); + } } - } else { - console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`); - process.exit(1); - } - }); + }, + ); program .command('followers') .description('Get users that follow you (or another user)') .option('--user ', 'User ID to get followers for (defaults to current user)') - .option('-n, --count ', 'Number of users to fetch', '20') + .option('-n, --count ', 'Number of users to fetch per page', '20') + .option('--cursor ', 'Cursor for pagination (from previous response)') + .option('--all', 'Fetch all users (paginate automatically)') + .option('--max-pages ', 'Stop after N pages when using --all') .option('--json', 'Output as JSON') - .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { - const opts = program.opts(); - const timeoutMs = ctx.resolveTimeoutFromOptions(opts); - const count = Number.parseInt(cmdOpts.count || '20', 10); + .action( + async (cmdOpts: { + user?: string; + count?: string; + cursor?: string; + all?: boolean; + maxPages?: string; + json?: boolean; + }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; - const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - - for (const warning of warnings) { - console.error(`${ctx.p('warn')}${warning}`); - } - - if (!cookies.authToken || !cookies.ct0) { - console.error(`${ctx.p('err')}Missing required credentials`); - process.exit(1); - } - - const client = new TwitterClient({ cookies, timeoutMs }); - - let userId = cmdOpts.user; - if (!userId) { - const currentUser = await client.getCurrentUser(); - if (!currentUser.success || !currentUser.user?.id) { - console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + const usePagination = cmdOpts.all || cmdOpts.cursor; + if (maxPages !== undefined && !cmdOpts.all) { + console.error(`${ctx.p('err')}--max-pages requires --all.`); + process.exit(1); + } + if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { + console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); process.exit(1); } - userId = currentUser.user.id; - } - const result = await client.getFollowers(userId, count); + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - if (result.success && result.users) { - if (cmdOpts.json) { - console.log(JSON.stringify(result.users, null, 2)); - } else { - if (result.users.length === 0) { - console.log('No users found.'); - } else { + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + + let userId = cmdOpts.user; + if (!userId) { + const currentUser = await client.getCurrentUser(); + if (!currentUser.success || !currentUser.user?.id) { + console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + process.exit(1); + } + userId = currentUser.user.id; + } + + if (cmdOpts.all) { + // Fetch all pages + const allUsers: TwitterUser[] = []; + const seen = new Set(); + let cursor: string | undefined = cmdOpts.cursor; + let pageNum = 0; + let nextCursor: string | undefined; + + while (true) { + pageNum++; + if (!cmdOpts.json) { + console.error(`${ctx.p('info')}Fetching page ${pageNum}...`); + } + + const result = await client.getFollowers(userId, count, cursor); + + if (!result.success || !result.users) { + console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`); + process.exit(1); + } + + let added = 0; for (const user of result.users) { + if (!seen.has(user.id)) { + seen.add(user.id); + allUsers.push(user); + added += 1; + } + } + + const pageCursor = result.nextCursor; + if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) { + nextCursor = undefined; + break; + } + + if (maxPages && pageNum >= maxPages) { + nextCursor = pageCursor; + break; + } + + cursor = pageCursor; + + // Rate limit: wait between pages to avoid overwhelming the API + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + if (cmdOpts.json) { + console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2)); + } else { + console.error(`${ctx.p('info')}Total: ${allUsers.length} users`); + if (nextCursor) { + console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`); + console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`); + } + for (const user of allUsers) { console.log(`@${user.username} (${user.name})`); if (user.description) { console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`); @@ -118,12 +286,43 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { console.log('──────────────────────────────────────────────────'); } } + } else { + // Single page fetch + const result = await client.getFollowers(userId, count, cmdOpts.cursor); + + if (result.success && result.users) { + if (cmdOpts.json) { + if (usePagination) { + console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2)); + } else { + console.log(JSON.stringify(result.users, null, 2)); + } + } else { + if (result.users.length === 0) { + console.log('No users found.'); + } else { + for (const user of result.users) { + console.log(`@${user.username} (${user.name})`); + if (user.description) { + console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`); + } + if (user.followersCount !== undefined) { + console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`); + } + console.log('──────────────────────────────────────────────────'); + } + if (result.nextCursor) { + console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`); + } + } + } + } else { + console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`); + process.exit(1); + } } - } else { - console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`); - process.exit(1); - } - }); + }, + ); program .command('likes') diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 67d4768..b0118a7 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -280,6 +280,8 @@ export interface FollowingResult { success: boolean; users?: TwitterUser[]; error?: string; + /** Cursor for fetching the next page of results */ + nextCursor?: string; } export interface TwitterClientOptions { diff --git a/src/lib/twitter-client-users.ts b/src/lib/twitter-client-users.ts index 227404a..3736195 100644 --- a/src/lib/twitter-client-users.ts +++ b/src/lib/twitter-client-users.ts @@ -7,12 +7,12 @@ import { } from './twitter-client-constants.js'; import { buildFollowingFeatures } from './twitter-client-features.js'; import type { CurrentUserResult, FollowingResult } from './twitter-client-types.js'; -import { parseUsersFromInstructions } from './twitter-client-utils.js'; +import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js'; export interface TwitterClientUserMethods { getCurrentUser(): Promise; - getFollowing(userId: string, count?: number): Promise; - getFollowers(userId: string, count?: number): Promise; + getFollowing(userId: string, count?: number, cursor?: string): Promise; + getFollowers(userId: string, count?: number, cursor?: string): Promise; } export function withUsers>( @@ -309,13 +309,17 @@ export function withUsers>( /** * Get users that a user is following */ - async getFollowing(userId: string, count = 20): Promise { - const variables = { + async getFollowing(userId: string, count = 20, cursor?: string): Promise { + const variables: Record = { userId, count, includePromotedContent: false, }; + if (cursor) { + variables.cursor = cursor; + } + const features = buildFollowingFeatures(); const params = new URLSearchParams({ @@ -369,8 +373,11 @@ export function withUsers>( const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; const users = parseUsersFromInstructions(instructions); + const nextCursor = extractCursorFromInstructions( + instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined, + ); - return { success: true as const, users, had404 }; + return { success: true as const, users, nextCursor, had404 }; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } @@ -381,18 +388,19 @@ export function withUsers>( const firstAttempt = await tryOnce(); if (firstAttempt.success) { - return { success: true, users: firstAttempt.users }; + return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor }; } if (firstAttempt.had404) { await this.refreshQueryIds(); const secondAttempt = await tryOnce(); if (secondAttempt.success) { - return { success: true, users: secondAttempt.users }; + return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor }; } // GraphQL Following can also return 404 (queryId churn / endpoint flakiness). // Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key). + // Note: REST fallback does not support cursor pagination. const restAttempt = await this.getFollowingViaRest(userId, count); if (restAttempt.success) { return restAttempt; @@ -407,13 +415,17 @@ export function withUsers>( /** * Get users that follow a user */ - async getFollowers(userId: string, count = 20): Promise { - const variables = { + async getFollowers(userId: string, count = 20, cursor?: string): Promise { + const variables: Record = { userId, count, includePromotedContent: false, }; + if (cursor) { + variables.cursor = cursor; + } + const features = buildFollowingFeatures(); const params = new URLSearchParams({ @@ -467,8 +479,11 @@ export function withUsers>( const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; const users = parseUsersFromInstructions(instructions); + const nextCursor = extractCursorFromInstructions( + instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined, + ); - return { success: true as const, users, had404 }; + return { success: true as const, users, nextCursor, had404 }; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } @@ -479,18 +494,19 @@ export function withUsers>( const firstAttempt = await tryOnce(); if (firstAttempt.success) { - return { success: true, users: firstAttempt.users }; + return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor }; } if (firstAttempt.had404) { await this.refreshQueryIds(); const secondAttempt = await tryOnce(); if (secondAttempt.success) { - return { success: true, users: secondAttempt.users }; + return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor }; } // GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness). // Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key). + // Note: REST fallback does not support cursor pagination. const restAttempt = await this.getFollowersViaRest(userId, count); if (restAttempt.success) { return restAttempt; diff --git a/tests/commands.users.test.ts b/tests/commands.users.test.ts new file mode 100644 index 0000000..6d36c72 --- /dev/null +++ b/tests/commands.users.test.ts @@ -0,0 +1,113 @@ +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CliContext } from '../src/cli/shared.js'; +import { registerUserCommands } from '../src/commands/users.js'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +const baseCtx = { + resolveTimeoutFromOptions: () => undefined, + resolveCredentialsFromOptions: async () => ({ + cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' }, + warnings: [], + }), + p: () => '', + printTweets: () => undefined, +} as unknown as CliContext; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('users commands', () => { + it('requires --all when --max-pages is provided', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`); + }) as never); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await expect(program.parseAsync(['node', 'bird', 'following', '--max-pages', '2'])).rejects.toThrow('exit 1'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all.')); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it('emits pagination JSON when --cursor is provided', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'next-1', + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev', '--json']); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.users).toHaveLength(1); + expect(payload.nextCursor).toBe('next-1'); + }); + + it('emits pagination JSON for --all results', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'next-1', + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'bird', 'following', '--all', '--max-pages', '1', '--json']); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.users).toHaveLength(1); + expect(payload.nextCursor).toBe('next-1'); + }); + + it('stops pagination when cursor repeats or no new users are added', async () => { + vi.useFakeTimers(); + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + const followingSpy = vi.spyOn(TwitterClient.prototype, 'getFollowing'); + followingSpy + .mockResolvedValueOnce({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'cursor-1', + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const run = program.parseAsync(['node', 'bird', 'following', '--all', '--json']); + await vi.advanceTimersByTimeAsync(1000); + await run; + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.users).toHaveLength(1); + expect(payload.nextCursor).toBeNull(); + expect(followingSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/runtime-features.test.ts b/tests/runtime-features.test.ts index 018144d..4bd024f 100644 --- a/tests/runtime-features.test.ts +++ b/tests/runtime-features.test.ts @@ -29,6 +29,9 @@ describe('runtime-features', () => { likes: { likes_only: true, }, + lists: { + responsive_web_text_conversations_enabled: true, + }, }, }); @@ -41,6 +44,9 @@ describe('runtime-features', () => { const likes = buildLikesFeatures(); expect(likes.global_flag).toBe(true); expect(likes.likes_only).toBe(true); + + const lists = buildListsFeatures(); + expect(lists.responsive_web_text_conversations_enabled).toBe(true); }); it('refresh merges defaults with existing cache', async () => { diff --git a/tests/twitter-client.following-likes.test.ts b/tests/twitter-client.following-likes.test.ts index 872c649..606d47f 100644 --- a/tests/twitter-client.following-likes.test.ts +++ b/tests/twitter-client.following-likes.test.ts @@ -280,6 +280,160 @@ describe('TwitterClient following/followers', () => { expect(urls[1]).toContain('/Followers?'); expect(urls[2]).toContain('/followers/list.json?'); }); + + it('passes cursor parameter to following API and returns nextCursor', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + type: 'TimelineAddEntries', + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('1', 'alpha', 'Alpha'), + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'next-cursor-abc123', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise }; + clientPrivate.getFollowingQueryIds = async () => ['test']; + + const result = await client.getFollowing('123', 20, 'prev-cursor-xyz'); + + expect(result.success).toBe(true); + expect(result.users?.length).toBe(1); + expect(result.nextCursor).toBe('next-cursor-abc123'); + + // Verify cursor was passed in the request + const [url] = mockFetch.mock.calls[0]; + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.cursor).toBe('prev-cursor-xyz'); + }); + + it('passes cursor parameter to followers API and returns nextCursor', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('9', 'beta', 'Beta'), + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'followers-next-cursor', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise }; + clientPrivate.getFollowersQueryIds = async () => ['test']; + + const result = await client.getFollowers('456', 50, 'my-cursor'); + + expect(result.success).toBe(true); + expect(result.users?.[0].username).toBe('beta'); + expect(result.nextCursor).toBe('followers-next-cursor'); + + // Verify cursor was passed in the request + const [url] = mockFetch.mock.calls[0]; + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.cursor).toBe('my-cursor'); + }); + + it('returns undefined nextCursor when no cursor in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('1', 'only', 'Only'), + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise }; + clientPrivate.getFollowingQueryIds = async () => ['test']; + + const result = await client.getFollowing('123', 20); + + expect(result.success).toBe(true); + expect(result.nextCursor).toBeUndefined(); + }); }); describe('TwitterClient likes', () => { diff --git a/tests/twitter-client.lists.test.ts b/tests/twitter-client.lists.test.ts index ddeafa3..e5407e5 100644 --- a/tests/twitter-client.lists.test.ts +++ b/tests/twitter-client.lists.test.ts @@ -87,6 +87,45 @@ describe('TwitterClient lists', () => { expect(result.lists?.[0].owner?.username).toBe('testuser'); }); + it('includes required feature flags in list requests', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + await client.getOwnedLists(1); + + const [url] = mockFetch.mock.calls[0]; + const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); + expect(parsedFeatures.responsive_web_graphql_exclude_directive_enabled).toBe(true); + expect(parsedFeatures.blue_business_profile_image_shape_enabled).toBe(false); + expect(parsedFeatures.responsive_web_text_conversations_enabled).toBe(false); + expect(parsedFeatures.tweetypie_unmention_optimization_enabled).toBe(true); + expect(parsedFeatures.vibe_api_enabled).toBe(false); + expect(parsedFeatures.interactive_text_enabled).toBe(false); + }); + it('handles private lists correctly', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -326,6 +365,29 @@ describe('TwitterClient lists', () => { expect(result.error).toContain('Rate limit exceeded'); }); + it('returns missing feature flag errors from the API', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'missing required feature flag: responsive_web_graphql_exclude_directive_enabled' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + const result = await client.getOwnedLists(1); + + expect(result.success).toBe(false); + expect(result.error).toContain('missing required feature flag'); + }); + it('retries on 404 error after refreshing query IDs', async () => { mockFetch .mockResolvedValueOnce({