diff --git a/CHANGELOG.md b/CHANGELOG.md index a52db92..8509607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.6.1 — 2026-01-11 +### Added +- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. + ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. - Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk. diff --git a/README.md b/README.md index 658de3f..215cad5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ bird unbookmark https://x.com/user/status/1234567890123456789 # Likes bird likes -n 5 +# Lists +bird list-timeline 1234567890 -n 20 +bird list-timeline https://x.com/i/lists/1234567890 --all --json +bird list-timeline 1234567890 --max-pages 3 --json + # Following (who you follow) bird following -n 20 bird following --user 12345678 -n 10 # by user ID @@ -98,6 +103,7 @@ const result = await client.search('from:steipete', 50); - `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`. - `bird unbookmark ` — remove one or more bookmarks by tweet ID or URL. - `bird likes [-n count] [--json]` — list your liked tweets. +- `bird list-timeline [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`. - `bird following [--user ] [-n count] [--json]` — list users that you (or another user) follow. - `bird followers [--user ] [-n count] [--json]` — list users that follow you (or another user). - `bird whoami` — print which Twitter account your cookies belong to. diff --git a/docs/testing.md b/docs/testing.md index 982487a..9775421 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,6 +16,7 @@ Requirements: Run: - `pnpm test:live` - `pnpm bird following --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default` +- `pnpm bird list-timeline --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default` Notes: - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). diff --git a/src/commands/lists.ts b/src/commands/lists.ts index ac2eb34..3fe7d45 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -78,42 +78,76 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { .command('list-timeline ') .description('Get tweets from a list timeline') .option('-n, --count ', 'Number of tweets to fetch', '20') + .option('--all', 'Fetch all tweets from list (paged). WARNING: your account might get banned using this flag') + .option('--max-pages ', 'Fetch N pages (implies --all)') + .option('--cursor ', 'Resume pagination from a cursor') .option('--json', 'Output as JSON') .option('--json-full', 'Output as JSON with full raw API response in _raw field') - .action(async (listIdOrUrl: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => { - const opts = program.opts(); - const timeoutMs = ctx.resolveTimeoutFromOptions(opts); - const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); - const count = Number.parseInt(cmdOpts.count || '20', 10); + .action( + async ( + listIdOrUrl: string, + cmdOpts: { + count?: string; + json?: boolean; + jsonFull?: boolean; + all?: boolean; + maxPages?: string; + cursor?: string; + }, + ) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; - 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 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); + const usePagination = cmdOpts.all || cmdOpts.cursor || maxPages !== undefined; + if (!usePagination && (!Number.isFinite(count) || count <= 0)) { + console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); + 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); + } - for (const warning of warnings) { - console.error(`${ctx.p('warn')}${warning}`); - } + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - if (!cookies.authToken || !cookies.ct0) { - console.error(`${ctx.p('err')}Missing required credentials`); - process.exit(1); - } + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } - const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); - const result = await client.getListTimeline(listId, count, { includeRaw: cmdOpts.jsonFull }); + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } - if (result.success && result.tweets) { - ctx.printTweets(result.tweets, { - json: cmdOpts.json || cmdOpts.jsonFull, - emptyMessage: 'No tweets found in this list.', - }); - } else { - console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`); - process.exit(1); - } - }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); + const includeRaw = cmdOpts.jsonFull ?? false; + const timelineOptions = { includeRaw }; + const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor }; + + const result = usePagination + ? await client.getAllListTimeline(listId, paginationOptions) + : await client.getListTimeline(listId, count, timelineOptions); + + 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.' }); + } + } else { + console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`); + process.exit(1); + } + }, + ); } diff --git a/src/lib/twitter-client-lists.ts b/src/lib/twitter-client-lists.ts index 4929970..d1f1e33 100644 --- a/src/lib/twitter-client-lists.ts +++ b/src/lib/twitter-client-lists.ts @@ -4,14 +4,15 @@ import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { buildListsFeatures } from './twitter-client-features.js'; -import type { TimelineFetchOptions } from './twitter-client-timelines.js'; -import type { GraphqlTweetResult, ListsResult, SearchResult, TwitterList } from './twitter-client-types.js'; -import { parseTweetsFromInstructions } from './twitter-client-utils.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'; export interface TwitterClientListMethods { getOwnedLists(count?: number): Promise; getListMemberships(count?: number): Promise; getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise; + getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise; } interface GraphqlListResult { @@ -329,25 +330,49 @@ export function withLists>( * Get tweets from a list timeline */ async getListTimeline(listId: string, count = 20, options: TimelineFetchOptions = {}): Promise { - const { includeRaw = false } = options; + return this.getListTimelinePaged(listId, count, options); + } - const variables = { - listId, - count, - }; + /** + * Get all tweets from a list timeline (paginated) + */ + async getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise { + return this.getListTimelinePaged(listId, Number.POSITIVE_INFINITY, options); + } + /** + * Internal paginated list timeline fetcher + */ + private async getListTimelinePaged( + listId: string, + limit: number, + options: TimelinePaginationOptions = {}, + ): Promise { const features = buildListsFeatures(); + const pageSize = 20; + const seen = new Set(); + const tweets: TweetData[] = []; + let cursor: string | undefined = options.cursor; + let nextCursor: string | undefined; + let pagesFetched = 0; + const { includeRaw = false, maxPages } = options; - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { + const fetchPage = async (pageCount: number, pageCursor?: string) => { let lastError: string | undefined; let had404 = false; const queryIds = await this.getListTimelineQueryIds(); + const variables = { + listId, + count: pageCount, + ...(pageCursor ? { cursor: pageCursor } : {}), + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + for (const queryId of queryIds) { const url = `${TWITTER_API_BASE}/${queryId}/ListLatestTweetsTimeline?${params.toString()}`; @@ -396,9 +421,10 @@ export function withLists>( } const instructions = data.data?.list?.tweets_timeline?.timeline?.instructions; - const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); + const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); + const nextCursor = extractCursorFromInstructions(instructions); - return { success: true as const, tweets, had404 }; + return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 }; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } @@ -407,21 +433,58 @@ export function withLists>( return { success: false as const, error: lastError ?? 'Unknown error fetching list timeline', had404 }; }; - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, tweets: firstAttempt.tweets }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, tweets: secondAttempt.tweets }; + const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => { + const firstAttempt = await fetchPage(pageCount, pageCursor); + if (firstAttempt.success) { + return firstAttempt; } - return { success: false, error: secondAttempt.error }; + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await fetchPage(pageCount, pageCursor); + if (secondAttempt.success) { + return secondAttempt; + } + return { success: false as const, error: secondAttempt.error }; + } + return { success: false as const, error: firstAttempt.error }; + }; + + const unlimited = !Number.isFinite(limit); + while (unlimited || tweets.length < limit) { + const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length); + const page = await fetchWithRefresh(pageCount, cursor); + if (!page.success) { + return { success: false, error: page.error }; + } + pagesFetched += 1; + + let added = 0; + for (const tweet of page.tweets) { + if (seen.has(tweet.id)) { + continue; + } + seen.add(tweet.id); + tweets.push(tweet); + added += 1; + if (!unlimited && tweets.length >= limit) { + break; + } + } + + const pageCursor = page.cursor; + if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) { + nextCursor = undefined; + break; + } + if (maxPages && pagesFetched >= maxPages) { + nextCursor = pageCursor; + break; + } + cursor = pageCursor; + nextCursor = pageCursor; } - return { success: false, error: firstAttempt.error }; + return { success: true, tweets, nextCursor }; } } diff --git a/tests/twitter-client.lists.memberships.test.ts b/tests/twitter-client.lists.memberships.test.ts new file mode 100644 index 0000000..136782a --- /dev/null +++ b/tests/twitter-client.lists.memberships.test.ts @@ -0,0 +1,137 @@ +// 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 memberships', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('getListMemberships', () => { + it('fetches list memberships and parses list results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '5555', + name: 'Member List', + member_count: 100, + user_results: { + result: { + rest_id: '99999', + legacy: { screen_name: 'otheruser', name: 'Other User' }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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.getListMembershipsQueryIds = async () => ['test']; + + const result = await client.getListMemberships(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].id).toBe('5555'); + expect(result.lists?.[0].name).toBe('Member List'); + expect(result.lists?.[0].owner?.username).toBe('otheruser'); + }); + + 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: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '6666', + name: 'Retry Membership List', + mode: 'Public', + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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.getListMembershipsQueryIds = async () => ['test']; + clientPrivate.refreshQueryIds = async () => {}; + + const result = await client.getListMemberships(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].id).toBe('6666'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/twitter-client.lists.owned.errors.test.ts b/tests/twitter-client.lists.owned.errors.test.ts new file mode 100644 index 0000000..9e1cd6c --- /dev/null +++ b/tests/twitter-client.lists.owned.errors.test.ts @@ -0,0 +1,292 @@ +// 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 owned errors', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('getOwnedLists error paths', () => { + it('returns error on API 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.getCurrentUser = async () => ({ + success: true, + user: { id: '12345', username: 'testuser', name: 'Test User' }, + }); + clientPrivate.getListOwnershipsQueryIds = async () => ['test']; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns error when getCurrentUser fails', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: false, + error: 'Unauthorized', + }); + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(false); + // When getCurrentUser fails with an error, that error is used; otherwise fallback message is used + expect(result.error).toContain('Unauthorized'); + }); + + it('handles API errors in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'Rate limit exceeded' }], + }), + }); + + 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(100); + + expect(result.success).toBe(false); + 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({ + ok: false, + status: 404, + text: async () => 'Not Found', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '333', + name: 'Retry List', + mode: 'Public', + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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']; + clientPrivate.refreshQueryIds = async () => {}; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].id).toBe('333'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('skips list entries with missing id_str or name', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '111', + name: 'Valid List', + }, + }, + }, + }, + { + content: { + itemContent: { + list: { + // Missing id_str + name: 'Invalid List 1', + }, + }, + }, + }, + { + content: { + itemContent: { + list: { + id_str: '222', + // Missing name + }, + }, + }, + }, + { + content: { + itemContent: { + list: { + id_str: '333', + name: 'Another Valid List', + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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(100); + + expect(result.success).toBe(true); + expect(result.lists).toHaveLength(2); + expect(result.lists?.[0].id).toBe('111'); + expect(result.lists?.[1].id).toBe('333'); + }); + + it('handles list with missing owner gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '444', + name: 'List Without Owner', + // No user_results + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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(100); + + expect(result.success).toBe(true); + expect(result.lists).toHaveLength(1); + expect(result.lists?.[0].owner).toBeUndefined(); + }); + }); +}); diff --git a/tests/twitter-client.lists.owned.test.ts b/tests/twitter-client.lists.owned.test.ts new file mode 100644 index 0000000..865fe9d --- /dev/null +++ b/tests/twitter-client.lists.owned.test.ts @@ -0,0 +1,308 @@ +// 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 owned', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('getOwnedLists', () => { + it('fetches owned lists and parses list results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '1234567890', + name: 'My Test List', + description: 'A test list for testing', + member_count: 10, + subscriber_count: 5, + mode: 'Public', + created_at: '2024-01-01T00:00:00Z', + user_results: { + result: { + rest_id: '12345', + legacy: { screen_name: 'testuser', name: 'Test User' }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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(100); + + expect(result.success).toBe(true); + expect(result.lists).toHaveLength(1); + expect(result.lists?.[0].id).toBe('1234567890'); + expect(result.lists?.[0].name).toBe('My Test List'); + expect(result.lists?.[0].description).toBe('A test list for testing'); + expect(result.lists?.[0].memberCount).toBe(10); + expect(result.lists?.[0].subscriberCount).toBe(5); + expect(result.lists?.[0].isPrivate).toBe(false); + 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, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '9999', + name: 'Secret List', + mode: 'Private', + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].isPrivate).toBe(true); + }); + + it('handles lowercase private mode', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '8888', + name: 'Lowercase Private', + mode: 'private', + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].isPrivate).toBe(true); + }); + + it('handles missing mode', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + list: { + id_str: '9999', + name: 'No Mode', + mode: null, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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(100); + + expect(result.success).toBe(true); + expect(result.lists?.[0].isPrivate).toBe(false); + }); + + it('returns empty array when no lists exist', 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']; + + const result = await client.getOwnedLists(100); + + expect(result.success).toBe(true); + expect(result.lists).toEqual([]); + }); + }); +}); diff --git a/tests/twitter-client.lists.pagination.edges.test.ts b/tests/twitter-client.lists.pagination.edges.test.ts new file mode 100644 index 0000000..b2aa1b6 --- /dev/null +++ b/tests/twitter-client.lists.pagination.edges.test.ts @@ -0,0 +1,463 @@ +// ABOUTME: Tests for paginated list timeline fetches. + +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 pagination (continued)', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('resumes from cursor', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet-resumed', + legacy: { + full_text: 'Resumed tweet', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'tweet-resumed', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getAllListTimeline('1234567890', { cursor: 'resume-cursor' }); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(1); + expect(result.tweets?.[0].id).toBe('tweet-resumed'); + + // Verify cursor was passed in the request + const callUrl = (mockFetch.mock.calls[0] as [string])[0]; + expect(callUrl).toContain('resume-cursor'); + }); + + it('deduplicates tweets across pages', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'duplicate-tweet', + legacy: { + full_text: 'Duplicate', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'duplicate-tweet', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-2', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'duplicate-tweet', + legacy: { + full_text: 'Duplicate', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'duplicate-tweet', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'unique-tweet', + legacy: { + full_text: 'Unique', + created_at: '2024-01-02T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'unique-tweet', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'user2', name: 'User 2' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getAllListTimeline('1234567890'); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(2); + expect(result.tweets?.[0].id).toBe('duplicate-tweet'); + expect(result.tweets?.[1].id).toBe('unique-tweet'); + }); + + it('stops when a page only returns duplicates', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'dup', + legacy: { + full_text: 'Duplicate', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'dup', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-2', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'dup', + legacy: { + full_text: 'Duplicate', + created_at: '2024-01-02T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'dup', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-3', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getAllListTimeline('1234567890'); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(1); + expect(result.nextCursor).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('stops when no cursor is returned', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet1', + legacy: { + full_text: 'Page 1', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'tweet1', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-2', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet2', + legacy: { + full_text: 'Last page', + created_at: '2024-01-02T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'tweet2', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'user2', name: 'User 2' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getAllListTimeline('1234567890'); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(2); + expect(result.nextCursor).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/twitter-client.lists.pagination.test.ts b/tests/twitter-client.lists.pagination.test.ts new file mode 100644 index 0000000..347243f --- /dev/null +++ b/tests/twitter-client.lists.pagination.test.ts @@ -0,0 +1,265 @@ +// ABOUTME: Tests for paginated list timeline fetches. + +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 pagination', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('getAllListTimeline', () => { + it('fetches multiple pages and returns all tweets', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet1', + legacy: { + full_text: 'First tweet', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'tweet1', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-2', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet2', + legacy: { + full_text: 'Second tweet', + created_at: '2024-01-02T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'tweet2', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'user2', name: 'User 2' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getAllListTimeline('1234567890'); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(2); + expect(result.tweets?.[0].id).toBe('tweet1'); + expect(result.tweets?.[1].id).toBe('tweet2'); + expect(result.nextCursor).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify cursor was passed in second request + const secondCallUrl = (mockFetch.mock.calls[1] as [string])[0]; + expect(secondCallUrl).toContain('cursor-page-2'); + }); + + it('stops at maxPages limit and returns nextCursor', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet1', + legacy: { + full_text: 'Page 1', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'tweet1', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'user1', name: 'User 1' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-2', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet2', + legacy: { + full_text: 'Page 2', + created_at: '2024-01-02T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: 'tweet2', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'user2', name: 'User 2' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-3', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getAllListTimeline('1234567890', { maxPages: 2 }); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(2); + expect(result.nextCursor).toBe('cursor-page-3'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/twitter-client.lists.test.ts b/tests/twitter-client.lists.test.ts deleted file mode 100644 index e5407e5..0000000 --- a/tests/twitter-client.lists.test.ts +++ /dev/null @@ -1,887 +0,0 @@ -// ABOUTME: Tests for TwitterClient list methods. -// ABOUTME: Tests getOwnedLists, getListMemberships, and getListTimeline. - -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', () => { - let mockFetch: ReturnType; - - beforeEach(() => { - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - }); - - describe('getOwnedLists', () => { - it('fetches owned lists and parses list results', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '1234567890', - name: 'My Test List', - description: 'A test list for testing', - member_count: 10, - subscriber_count: 5, - mode: 'Public', - created_at: '2024-01-01T00:00:00Z', - user_results: { - result: { - rest_id: '12345', - legacy: { screen_name: 'testuser', name: 'Test User' }, - }, - }, - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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(100); - - expect(result.success).toBe(true); - expect(result.lists).toHaveLength(1); - expect(result.lists?.[0].id).toBe('1234567890'); - expect(result.lists?.[0].name).toBe('My Test List'); - expect(result.lists?.[0].description).toBe('A test list for testing'); - expect(result.lists?.[0].memberCount).toBe(10); - expect(result.lists?.[0].subscriberCount).toBe(5); - expect(result.lists?.[0].isPrivate).toBe(false); - 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, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '9999', - name: 'Secret List', - mode: 'Private', - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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(100); - - expect(result.success).toBe(true); - expect(result.lists?.[0].isPrivate).toBe(true); - }); - - it('handles lowercase private mode', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '8888', - name: 'Lowercase Private', - mode: 'private', - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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(100); - - expect(result.success).toBe(true); - expect(result.lists?.[0].isPrivate).toBe(true); - }); - - it('handles missing mode', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '9999', - name: 'No Mode', - mode: null, - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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(100); - - expect(result.success).toBe(true); - expect(result.lists?.[0].isPrivate).toBe(false); - }); - - it('returns empty array when no lists exist', 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']; - - const result = await client.getOwnedLists(100); - - expect(result.success).toBe(true); - expect(result.lists).toEqual([]); - }); - - it('returns error on API 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.getCurrentUser = async () => ({ - success: true, - user: { id: '12345', username: 'testuser', name: 'Test User' }, - }); - clientPrivate.getListOwnershipsQueryIds = async () => ['test']; - - const result = await client.getOwnedLists(100); - - expect(result.success).toBe(false); - expect(result.error).toContain('HTTP 500'); - }); - - it('returns error when getCurrentUser fails', async () => { - const client = new TwitterClient({ cookies: validCookies }); - const clientPrivate = client as unknown as TwitterClientPrivate; - clientPrivate.getCurrentUser = async () => ({ - success: false, - error: 'Unauthorized', - }); - - const result = await client.getOwnedLists(100); - - expect(result.success).toBe(false); - // When getCurrentUser fails with an error, that error is used; otherwise fallback message is used - expect(result.error).toContain('Unauthorized'); - }); - - it('handles API errors in response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - errors: [{ message: 'Rate limit exceeded' }], - }), - }); - - 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(100); - - expect(result.success).toBe(false); - 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({ - ok: false, - status: 404, - text: async () => 'Not Found', - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '333', - name: 'Retry List', - mode: 'Public', - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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']; - clientPrivate.refreshQueryIds = async () => {}; - - const result = await client.getOwnedLists(100); - - expect(result.success).toBe(true); - expect(result.lists?.[0].id).toBe('333'); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('skips list entries with missing id_str or name', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '111', - name: 'Valid List', - }, - }, - }, - }, - { - content: { - itemContent: { - list: { - // Missing id_str - name: 'Invalid List 1', - }, - }, - }, - }, - { - content: { - itemContent: { - list: { - id_str: '222', - // Missing name - }, - }, - }, - }, - { - content: { - itemContent: { - list: { - id_str: '333', - name: 'Another Valid List', - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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(100); - - expect(result.success).toBe(true); - expect(result.lists).toHaveLength(2); - expect(result.lists?.[0].id).toBe('111'); - expect(result.lists?.[1].id).toBe('333'); - }); - - it('handles list with missing owner gracefully', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '444', - name: 'List Without Owner', - // No user_results - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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(100); - - expect(result.success).toBe(true); - expect(result.lists).toHaveLength(1); - expect(result.lists?.[0].owner).toBeUndefined(); - }); - }); - - describe('getListMemberships', () => { - it('fetches list memberships and parses list results', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '5555', - name: 'Member List', - member_count: 100, - user_results: { - result: { - rest_id: '99999', - legacy: { screen_name: 'otheruser', name: 'Other User' }, - }, - }, - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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.getListMembershipsQueryIds = async () => ['test']; - - const result = await client.getListMemberships(100); - - expect(result.success).toBe(true); - expect(result.lists?.[0].id).toBe('5555'); - expect(result.lists?.[0].name).toBe('Member List'); - expect(result.lists?.[0].owner?.username).toBe('otheruser'); - }); - - 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: { - user: { - result: { - timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - list: { - id_str: '6666', - name: 'Retry Membership List', - mode: 'Public', - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }, - }), - }); - - 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.getListMembershipsQueryIds = async () => ['test']; - clientPrivate.refreshQueryIds = async () => {}; - - const result = await client.getListMemberships(100); - - expect(result.success).toBe(true); - expect(result.lists?.[0].id).toBe('6666'); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - }); - - describe('getListTimeline', () => { - it('fetches list timeline and parses tweet results', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - list: { - tweets_timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - tweet_results: { - result: { - rest_id: '111', - legacy: { - full_text: 'Tweet from list', - created_at: '2024-01-01T00:00:00Z', - reply_count: 0, - retweet_count: 0, - favorite_count: 0, - conversation_id_str: '111', - }, - core: { - user_results: { - result: { - rest_id: 'u1', - legacy: { screen_name: 'listmember', name: 'List Member' }, - }, - }, - }, - }, - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const clientPrivate = client as unknown as TwitterClientPrivate; - clientPrivate.getListTimelineQueryIds = async () => ['test']; - - const result = await client.getListTimeline('1234567890', 20); - - expect(result.success).toBe(true); - expect(result.tweets?.[0].id).toBe('111'); - expect(result.tweets?.[0].text).toBe('Tweet from list'); - expect(result.tweets?.[0].author.username).toBe('listmember'); - }); - - it('returns empty array when list has no tweets', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - list: { - tweets_timeline: { - timeline: { - instructions: [], - }, - }, - }, - }, - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const clientPrivate = client as unknown as TwitterClientPrivate; - clientPrivate.getListTimelineQueryIds = async () => ['test']; - - const result = await client.getListTimeline('1234567890', 20); - - expect(result.success).toBe(true); - expect(result.tweets).toEqual([]); - }); - - it('returns error on API 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.getListTimelineQueryIds = async () => ['test']; - - const result = await client.getListTimeline('1234567890', 20); - - 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: 'List not found' }], - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const clientPrivate = client as unknown as TwitterClientPrivate; - clientPrivate.getListTimelineQueryIds = async () => ['test']; - - const result = await client.getListTimeline('nonexistent', 20); - - expect(result.success).toBe(false); - expect(result.error).toContain('List not found'); - }); - - 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: { - tweets_timeline: { - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - tweet_results: { - result: { - rest_id: '222', - legacy: { - full_text: 'Retry success', - created_at: '2024-01-01T00:00:00Z', - reply_count: 0, - retweet_count: 0, - favorite_count: 0, - conversation_id_str: '222', - }, - core: { - user_results: { - result: { - rest_id: 'u2', - legacy: { screen_name: 'user2', name: 'User Two' }, - }, - }, - }, - }, - }, - }, - }, - }, - ], - }, - ], - }, - }, - }, - }, - }), - }); - - const client = new TwitterClient({ cookies: validCookies }); - const clientPrivate = client as unknown as TwitterClientPrivate; - clientPrivate.getListTimelineQueryIds = async () => ['test']; - clientPrivate.refreshQueryIds = async () => {}; - - const result = await client.getListTimeline('1234567890', 20); - - expect(result.success).toBe(true); - expect(result.tweets?.[0].id).toBe('222'); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/tests/twitter-client.lists.timeline.test.ts b/tests/twitter-client.lists.timeline.test.ts new file mode 100644 index 0000000..be84842 --- /dev/null +++ b/tests/twitter-client.lists.timeline.test.ts @@ -0,0 +1,214 @@ +// 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 timeline', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + describe('getListTimeline', () => { + it('fetches list timeline and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '111', + legacy: { + full_text: 'Tweet from list', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '111', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'listmember', name: 'List Member' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('111'); + expect(result.tweets?.[0].text).toBe('Tweet from list'); + expect(result.tweets?.[0].author.username).toBe('listmember'); + }); + + it('returns empty array when list has no tweets', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.tweets).toEqual([]); + }); + + it('returns error on API 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.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('1234567890', 20); + + 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: 'List not found' }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('nonexistent', 20); + + expect(result.success).toBe(false); + expect(result.error).toContain('List not found'); + }); + + 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: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '222', + legacy: { + full_text: 'Retry success', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '222', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'user2', name: 'User Two' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + clientPrivate.refreshQueryIds = async () => {}; + + const result = await client.getListTimeline('1234567890', 20); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('222'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +});