diff --git a/src/cli/program.ts b/src/cli/program.ts index 6c5c605..613dd0f 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -32,6 +32,7 @@ export const KNOWN_COMMANDS = new Set([ 'following', 'followers', 'likes', + 'list', 'lists', 'list-timeline', 'home', diff --git a/src/commands/lists.ts b/src/commands/lists.ts index d7297b7..dfb01ea 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -5,10 +5,41 @@ import type { Command } from 'commander'; import { parsePaginationFlags } from '../cli/pagination.js'; import type { CliContext } from '../cli/shared.js'; import { extractListId } from '../lib/extract-list-id.js'; +import { normalizeHandle } from '../lib/normalize-handle.js'; import { hyperlink } from '../lib/output.js'; -import type { TwitterList } from '../lib/twitter-client.js'; +import type { TwitterList, TwitterUser } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js'; +const ONLY_DIGITS_REGEX = /^\d+$/; + +async function resolveUserId( + client: TwitterClient, + usernameOrUrl: string, + ctx: CliContext, +): Promise<{ userId: string; username?: string } | null> { + const raw = usernameOrUrl.trim(); + const isNumeric = ONLY_DIGITS_REGEX.test(raw); + + const handle = normalizeHandle(raw); + if (handle) { + const lookup = await client.getUserIdByUsername(handle); + if (lookup.success && lookup.userId) { + return { userId: lookup.userId, username: lookup.username }; + } + if (!isNumeric) { + console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`); + return null; + } + } + + if (isNumeric) { + return { userId: raw }; + } + + console.error(`${ctx.p('err')}Invalid username: ${usernameOrUrl}`); + return null; +} + function printLists(lists: TwitterList[], ctx: CliContext): void { if (lists.length === 0) { console.log('No lists found.'); @@ -31,6 +62,18 @@ function printLists(lists: TwitterList[], ctx: CliContext): void { } } +function printMembers(members: TwitterUser[], ctx: CliContext): void { + for (const member of members) { + const verified = member.isBlueVerified ? ' ✓' : ''; + console.log(`@${member.username}${verified} - ${member.name}`); + if (member.description) { + console.log(` ${member.description.slice(0, 100)}${member.description.length > 100 ? '...' : ''}`); + } + console.log(` ${ctx.colors.muted(`${member.followersCount?.toLocaleString() ?? 0} followers`)}`); + console.log('──────────────────────────────────────────────────'); + } +} + export function registerListsCommand(program: Command, ctx: CliContext): void { program .command('lists') @@ -152,4 +195,252 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { } }, ); + + const listCmd = program.command('list').description('Manage Twitter lists'); + + // bird list members + listCmd + .command('members ') + .description('Get members of a list') + .option('-n, --count ', 'Number of members to fetch', '20') + .option('--cursor ', 'Resume pagination from a cursor') + .option('--all', 'Fetch all list members (paged)') + .option('--max-pages ', 'Stop after N pages when using --all') + .option('--json', 'Output as JSON') + .action( + async ( + listIdOrUrl: string, + cmdOpts: { count?: string; json?: boolean; cursor?: string; all?: boolean; maxPages?: string }, + ) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + + const pagination = parsePaginationFlags(cmdOpts); + if (!pagination.ok) { + console.error(`${ctx.p('err')}${pagination.error}`); + process.exit(1); + } + const usePagination = pagination.usePagination; + const maxPages = pagination.maxPages; + + const listId = extractListId(listIdOrUrl); + if (!listId) { + console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/.`); + process.exit(2); + } + + if (maxPages !== undefined && !usePagination) { + console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); + process.exit(1); + } + if (!Number.isFinite(count) || count <= 0) { + console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); + process.exit(1); + } + + 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 }); + + if (cmdOpts.all) { + const allMembers: TwitterUser[] = []; + const seen = new Set(); + let cursor: string | undefined = pagination.cursor; + let pageNum = 0; + let nextCursor: string | undefined; + + while (true) { + pageNum += 1; + if (!cmdOpts.json) { + console.error(`${ctx.p('info')}Fetching page ${pageNum}...`); + } + + const result = await client.getListMembers(listId, count, cursor); + if (!result.success || !result.members) { + console.error(`${ctx.p('err')}Failed to fetch list members: ${result.error}`); + process.exit(1); + } + + let added = 0; + for (const member of result.members) { + if (!seen.has(member.id)) { + seen.add(member.id); + allMembers.push(member); + added += 1; + } + } + + const pageCursor = result.nextCursor; + if (!pageCursor || result.members.length === 0 || added === 0 || pageCursor === cursor) { + nextCursor = undefined; + break; + } + + if (maxPages && pageNum >= maxPages) { + nextCursor = pageCursor; + break; + } + + cursor = pageCursor; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + if (cmdOpts.json) { + console.log(JSON.stringify({ members: allMembers, nextCursor: nextCursor ?? null }, null, 2)); + } else { + console.error(`${ctx.p('info')}Total: ${allMembers.length} members`); + if (nextCursor) { + console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`); + console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`); + } + if (allMembers.length === 0) { + console.log('No members found in this list.'); + } else { + printMembers(allMembers, ctx); + } + } + return; + } + + const result = await client.getListMembers(listId, count, pagination.cursor); + + if (result.success && result.members) { + if (cmdOpts.json) { + if (usePagination) { + console.log(JSON.stringify({ members: result.members, nextCursor: result.nextCursor ?? null }, null, 2)); + } else { + console.log(JSON.stringify(result.members, null, 2)); + } + } else { + if (result.members.length === 0) { + console.log('No members found in this list.'); + } else { + printMembers(result.members, ctx); + if (result.nextCursor) { + console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`); + } + } + } + } else { + console.error(`${ctx.p('err')}Failed to fetch list members: ${result.error}`); + process.exit(1); + } + }, + ); + + // bird list add + listCmd + .command('add ') + .description('Add a user to a list') + .option('--json', 'Output as JSON') + .action(async (listIdOrUrl: string, usernameOrUrl: string, cmdOpts: { json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + + const listId = extractListId(listIdOrUrl); + if (!listId) { + console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/.`); + process.exit(2); + } + + 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 }); + + const resolved = await resolveUserId(client, usernameOrUrl, ctx); + if (!resolved) { + process.exit(1); + } + + const { userId, username } = resolved; + const displayName = username ? `@${username}` : userId; + + const result = await client.addListMember(listId, userId); + if (result.success) { + if (cmdOpts.json) { + console.log(JSON.stringify({ success: true, listId, userId, username }, null, 2)); + } else { + console.log(`${ctx.p('ok')}Added ${displayName} to list ${listId}`); + } + } else { + if (cmdOpts.json) { + console.log(JSON.stringify({ success: false, error: result.error }, null, 2)); + } else { + console.error(`${ctx.p('err')}Failed to add ${displayName} to list: ${result.error}`); + } + process.exit(1); + } + }); + + // bird list remove + listCmd + .command('remove ') + .description('Remove a user from a list') + .option('--json', 'Output as JSON') + .action(async (listIdOrUrl: string, usernameOrUrl: string, cmdOpts: { json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + + const listId = extractListId(listIdOrUrl); + if (!listId) { + console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/.`); + process.exit(2); + } + + 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 }); + + const resolved = await resolveUserId(client, usernameOrUrl, ctx); + if (!resolved) { + process.exit(1); + } + + const { userId, username } = resolved; + const displayName = username ? `@${username}` : userId; + + const result = await client.removeListMember(listId, userId); + if (result.success) { + if (cmdOpts.json) { + console.log(JSON.stringify({ success: true, listId, userId, username }, null, 2)); + } else { + console.log(`${ctx.p('ok')}Removed ${displayName} from list ${listId}`); + } + } else { + if (cmdOpts.json) { + console.log(JSON.stringify({ success: false, error: result.error }, null, 2)); + } else { + console.error(`${ctx.p('err')}Failed to remove ${displayName} from list: ${result.error}`); + } + process.exit(1); + } + }); } diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index bcc70cc..cee3f34 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -32,10 +32,15 @@ export const FALLBACK_QUERY_IDS = { Followers: 'kuFUYP9eV1FPoEy4N-pi7w', Likes: 'JR2gceKucIKcVNB_9JkhsA', BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ', - ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg', - ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg', - ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw', + ListAddMember: 'EadD8ivrhZhYQr2pDmCpjA', ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw', + ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw', + ListMembers: 'YDKTAwm9knyfTvkf9ac3KQ', + ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg', + ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg', + ListRemoveMember: 'B5tMzrMYuFHJex_4EXFTSw', + ListsManagementPageTimeline: 'T1sihktFymBqBBx_GQVpWw', + CombinedLists: 'r44PTvU6LtyrSM0yf_FQCQ', HomeTimeline: 'edseUwk9sP5Phz__9TIRnA', HomeLatestTimeline: 'iOEZpOdfekFsxSlPQCQtPg', ExploreSidebar: 'lpSN4M6qpimkF4nRFPE3nQ', diff --git a/src/lib/twitter-client-lists.ts b/src/lib/twitter-client-lists.ts index 877d5a3..cae61d1 100644 --- a/src/lib/twitter-client-lists.ts +++ b/src/lib/twitter-client-lists.ts @@ -5,14 +5,17 @@ import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-cl import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { buildListsFeatures } from './twitter-client-features.js'; import type { TimelineFetchOptions, TimelinePaginationOptions } from './twitter-client-timelines.js'; -import type { GraphqlTweetResult, ListsResult, SearchResult, TweetData, TwitterList } from './twitter-client-types.js'; -import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js'; +import type { GraphqlTweetResult, ListMembersResult, ListMutationResult, ListsResult, SearchResult, TweetData, TwitterList } from './twitter-client-types.js'; +import { extractCursorFromInstructions, parseTweetsFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js'; export interface TwitterClientListMethods { getOwnedLists(count?: number): Promise; getListMemberships(count?: number): Promise; + getListMembers(listId: string, count?: number, cursor?: string): Promise; getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise; getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise; + addListMember(listId: string, userId: string): Promise; + removeListMember(listId: string, userId: string): Promise; } interface GraphqlListResult { @@ -118,6 +121,21 @@ export function withLists>( return Array.from(new Set([primary, '2TemLyqrMpTeAmysdbnVqw'])); } + private async getListAddMemberQueryIds(): Promise { + const primary = await this.getQueryId('ListAddMember'); + return Array.from(new Set([primary, 'EadD8ivrhZhYQr2pDmCpjA'])); + } + + private async getListRemoveMemberQueryIds(): Promise { + const primary = await this.getQueryId('ListRemoveMember'); + return Array.from(new Set([primary, 'B5tMzrMYuFHJex_4EXFTSw'])); + } + + private async getListMembersQueryIds(): Promise { + const primary = await this.getQueryId('ListMembers'); + return Array.from(new Set([primary, 'YDKTAwm9knyfTvkf9ac3KQ'])); + } + /** * Get lists owned by the authenticated user */ @@ -326,6 +344,99 @@ export function withLists>( return { success: false, error: firstAttempt.error }; } + /** + * Get members of a list + */ + async getListMembers(listId: string, count = 20, cursor?: string): Promise { + const features = buildListsFeatures(); + + const variables: Record = { + listId, + count, + }; + if (cursor) { + variables.cursor = cursor; + } + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getListMembersQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/ListMembers?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + list?: { + members_timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.list?.members_timeline?.timeline?.instructions; + const members = parseUsersFromInstructions(instructions); + const nextCursor = extractCursorFromInstructions( + instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined, + ); + + return { success: true as const, members, nextCursor, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching list members', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, members: firstAttempt.members, nextCursor: firstAttempt.nextCursor }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, members: secondAttempt.members, nextCursor: secondAttempt.nextCursor }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + /** * Get tweets from a list timeline */ @@ -486,6 +597,148 @@ export function withLists>( return { success: true, tweets, nextCursor }; } + + /** + * Add a user to a list + */ + async addListMember(listId: string, userId: string): Promise { + const features = buildListsFeatures(); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getListAddMemberQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/ListAddMember`; + const variables = { listId, userId }; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ variables, features, queryId }), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + list?: GraphqlListResult; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const list = data.data?.list ? parseList(data.data.list) : undefined; + return { success: true as const, list: list ?? undefined, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error adding list member', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, list: firstAttempt.list }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, list: secondAttempt.list }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + + /** + * Remove a user from a list + */ + async removeListMember(listId: string, userId: string): Promise { + const features = buildListsFeatures(); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getListRemoveMemberQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/ListRemoveMember`; + const variables = { listId, userId }; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ variables, features, queryId }), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + list?: GraphqlListResult; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const list = data.data?.list ? parseList(data.data.list) : undefined; + return { success: true as const, list: list ?? undefined, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error removing list member', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, list: firstAttempt.list }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, list: secondAttempt.list }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } } return TwitterClientLists; diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index a9639d7..54f9ada 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -387,6 +387,23 @@ export interface ListsResult { error?: string; } +export type ListMutationResult = + | { + success: true; + list?: TwitterList; + } + | { + success: false; + error: string; + }; + +export interface ListMembersResult { + success: boolean; + members?: TwitterUser[]; + error?: string; + nextCursor?: string; +} + export interface CreateTweetResponse { data?: { create_tweet?: { diff --git a/tests/twitter-client-fixtures.ts b/tests/twitter-client-fixtures.ts index 953547e..ccf4ca2 100644 --- a/tests/twitter-client-fixtures.ts +++ b/tests/twitter-client-fixtures.ts @@ -17,5 +17,8 @@ export type TwitterClientPrivate = TwitterClient & { getListTimelineQueryIds: () => Promise; getListOwnershipsQueryIds: () => Promise; getListMembershipsQueryIds: () => Promise; + getListMembersQueryIds: () => Promise; + getListAddMemberQueryIds: () => Promise; + getListRemoveMemberQueryIds: () => Promise; refreshQueryIds: () => Promise; }; diff --git a/tests/twitter-client.lists.members.test.ts b/tests/twitter-client.lists.members.test.ts new file mode 100644 index 0000000..c99b998 --- /dev/null +++ b/tests/twitter-client.lists.members.test.ts @@ -0,0 +1,232 @@ +// ABOUTME: Tests for TwitterClient list methods. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; +import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('TwitterClient lists members', () => { + let mockFetch: ReturnType; + + const makeUserResult = (id: string, username: string, name = username) => ({ + __typename: 'User', + rest_id: id, + is_blue_verified: true, + legacy: { + screen_name: username, + name, + description: `bio-${id}`, + followers_count: 10, + friends_count: 5, + profile_image_url_https: `https://example.com/${id}.jpg`, + created_at: '2024-01-01T00:00:00Z', + }, + }); + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('getListMembers', () => { + it('fetches list members and parses user results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + members_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('1', 'alpha', 'Alpha'), + }, + }, + }, + }, + { + content: { + itemContent: { + user_results: { + result: { __typename: 'User', rest_id: '2' }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListMembersQueryIds = async () => ['test']; + + const result = await client.getListMembers('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.members).toHaveLength(1); + expect(result.members?.[0].id).toBe('1'); + expect(result.members?.[0].username).toBe('alpha'); + expect(result.members?.[0].followersCount).toBe(10); + expect(result.members?.[0].followingCount).toBe(5); + expect(result.members?.[0].isBlueVerified).toBe(true); + expect(result.members?.[0].profileImageUrl).toBe('https://example.com/1.jpg'); + expect(result.members?.[0].createdAt).toBe('2024-01-01T00:00:00Z'); + }); + + it('passes cursor parameter and returns nextCursor', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + members_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('9', 'beta', 'Beta'), + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'members-next-cursor', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListMembersQueryIds = async () => ['test']; + + const result = await client.getListMembers('1234567890', 50, 'my-cursor'); + + expect(result.success).toBe(true); + expect(result.members?.[0].username).toBe('beta'); + expect(result.nextCursor).toBe('members-next-cursor'); + + 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 error on HTTP failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListMembersQueryIds = async () => ['test']; + + const result = await client.getListMembers('1234567890', 20); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('handles API errors in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'List members unavailable' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListMembersQueryIds = async () => ['test']; + + const result = await client.getListMembers('1234567890', 20); + + expect(result.success).toBe(false); + expect(result.error).toContain('List members unavailable'); + }); + + it('retries on 404 error after refreshing query IDs', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + members_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + user_results: { + result: makeUserResult('3', 'gamma', 'Gamma'), + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListMembersQueryIds = async () => ['test']; + clientPrivate.refreshQueryIds = async () => {}; + + const result = await client.getListMembers('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.members?.[0].id).toBe('3'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/twitter-client.lists.mutations.test.ts b/tests/twitter-client.lists.mutations.test.ts new file mode 100644 index 0000000..cfc8747 --- /dev/null +++ b/tests/twitter-client.lists.mutations.test.ts @@ -0,0 +1,221 @@ +// ABOUTME: Tests for TwitterClient list methods. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; +import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('TwitterClient lists mutations', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('addListMember', () => { + it('adds list member and parses list result', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + id_str: '123', + name: 'Added Members', + member_count: 42, + mode: 'Public', + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListAddMemberQueryIds = async () => ['test']; + + const result = await client.addListMember('123', '999'); + + expect(result.success).toBe(true); + expect(result.list?.id).toBe('123'); + expect(result.list?.name).toBe('Added Members'); + expect(result.list?.memberCount).toBe(42); + expect(result.list?.isPrivate).toBe(false); + }); + + it('returns error on HTTP failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListAddMemberQueryIds = async () => ['test']; + + const result = await client.addListMember('123', '999'); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('handles API errors in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'Cannot add member' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListAddMemberQueryIds = async () => ['test']; + + const result = await client.addListMember('123', '999'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Cannot add member'); + }); + + it('retries on 404 error after refreshing query IDs', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + id_str: '321', + name: 'Retry Add', + mode: 'Public', + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListAddMemberQueryIds = async () => ['test']; + clientPrivate.refreshQueryIds = async () => {}; + + const result = await client.addListMember('321', '999'); + + expect(result.success).toBe(true); + expect(result.list?.id).toBe('321'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('removeListMember', () => { + it('removes list member and parses list result', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + id_str: '555', + name: 'Removed Members', + member_count: 3, + mode: 'Private', + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListRemoveMemberQueryIds = async () => ['test']; + + const result = await client.removeListMember('555', '777'); + + expect(result.success).toBe(true); + expect(result.list?.id).toBe('555'); + expect(result.list?.name).toBe('Removed Members'); + expect(result.list?.memberCount).toBe(3); + expect(result.list?.isPrivate).toBe(true); + }); + + it('returns error on HTTP failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListRemoveMemberQueryIds = async () => ['test']; + + const result = await client.removeListMember('555', '777'); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 403'); + }); + + it('handles API errors in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'Cannot remove member' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListRemoveMemberQueryIds = async () => ['test']; + + const result = await client.removeListMember('555', '777'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Cannot remove member'); + }); + + it('retries on 404 error after refreshing query IDs', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + id_str: '999', + name: 'Retry Remove', + mode: 'Public', + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListRemoveMemberQueryIds = async () => ['test']; + clientPrivate.refreshQueryIds = async () => {}; + + const result = await client.removeListMember('999', '777'); + + expect(result.success).toBe(true); + expect(result.list?.id).toBe('999'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +});