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/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 9ad4273..3fe7d45 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -83,59 +83,71 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { .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; 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; + .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 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); - } - - 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, 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.' }); + 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); } - } else { - console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`); - process.exit(1); - } - }); + + 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); + } + + 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, 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 8f57c8b..d1f1e33 100644 --- a/src/lib/twitter-client-lists.ts +++ b/src/lib/twitter-client-lists.ts @@ -458,19 +458,21 @@ export function withLists>( } 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) { + if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) { nextCursor = undefined; break; } 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 6e1d8ec..0000000 --- a/tests/twitter-client.lists.test.ts +++ /dev/null @@ -1,1452 +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); - }); - }); - - 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); - }); - - 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 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.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); + }); + }); +});