diff --git a/src/commands/lists.ts b/src/commands/lists.ts index bd3ade0..7ec4cbe 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -3,31 +3,10 @@ import type { Command } from 'commander'; import type { CliContext } from '../cli/shared.js'; +import { extractListId } from '../lib/extract-list-id.js'; import type { TwitterList } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js'; -const NUMERIC_ID_REGEX = /^\d+$/; -const LIST_URL_REGEX = /(?:x\.com|twitter\.com)\/i\/lists\/(\d+)/i; - -function extractListId(listIdOrUrl: string): string | null { - const trimmed = listIdOrUrl.trim(); - - // Numeric ID - if (NUMERIC_ID_REGEX.test(trimmed)) { - return trimmed; - } - - // URL formats: - // https://x.com/i/lists/1234567890123456789 - // https://twitter.com/i/lists/1234567890123456789 - const urlMatch = trimmed.match(LIST_URL_REGEX); - if (urlMatch) { - return urlMatch[1]; - } - - return null; -} - function printLists(lists: TwitterList[], ctx: CliContext): void { if (lists.length === 0) { console.log('No lists found.'); diff --git a/src/lib/extract-list-id.ts b/src/lib/extract-list-id.ts new file mode 100644 index 0000000..16e8e28 --- /dev/null +++ b/src/lib/extract-list-id.ts @@ -0,0 +1,20 @@ +// ABOUTME: Extract list ID from an X/Twitter list URL. +// ABOUTME: Returns list ID or null if input is invalid. + +const LIST_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/lists\/(\d+)/i; +const LIST_ID_REGEX = /^\d{5,}$/; + +export function extractListId(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + const urlMatch = LIST_URL_REGEX.exec(trimmed); + if (urlMatch) { + return urlMatch[1]; + } + if (LIST_ID_REGEX.test(trimmed)) { + return trimmed; + } + return null; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a3f5a9d..b5993e5 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js'; +import { extractListId } from '../src/lib/extract-list-id.js'; import { extractTweetId } from '../src/lib/extract-tweet-id.js'; describe('CLI utilities', () => { @@ -56,4 +57,46 @@ describe('CLI utilities', () => { expect(extractBookmarkFolderId(url)).toBeNull(); }); }); + + describe('extractListId', () => { + it('should extract ID from x.com list URL', () => { + const url = 'https://x.com/i/lists/1234567890123456789'; + expect(extractListId(url)).toBe('1234567890123456789'); + }); + + it('should extract ID from twitter.com list URL', () => { + const url = 'https://twitter.com/i/lists/1234567890123456789'; + expect(extractListId(url)).toBe('1234567890123456789'); + }); + + it('should extract ID from URL with query params', () => { + const url = 'https://x.com/i/lists/1234567890123456789?s=20'; + expect(extractListId(url)).toBe('1234567890123456789'); + }); + + it('should accept a numeric ID as-is', () => { + const id = '1234567890123456789'; + expect(extractListId(id)).toBe('1234567890123456789'); + }); + + it('should return null for invalid values', () => { + expect(extractListId('not-an-id')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(extractListId('')).toBeNull(); + }); + + it('should return null for whitespace-only string', () => { + expect(extractListId(' ')).toBeNull(); + }); + + it('should return null for short numeric IDs', () => { + expect(extractListId('1234')).toBeNull(); + }); + + it('should trim whitespace from input', () => { + expect(extractListId(' 1234567890123456789 ')).toBe('1234567890123456789'); + }); + }); }); diff --git a/tests/twitter-client-fixtures.ts b/tests/twitter-client-fixtures.ts index e46225e..953547e 100644 --- a/tests/twitter-client-fixtures.ts +++ b/tests/twitter-client-fixtures.ts @@ -14,4 +14,8 @@ export type TwitterClientPrivate = TwitterClient & { error?: string; }>; getLikesQueryIds: () => Promise; + getListTimelineQueryIds: () => Promise; + getListOwnershipsQueryIds: () => Promise; + getListMembershipsQueryIds: () => Promise; + refreshQueryIds: () => Promise; }; diff --git a/tests/twitter-client.json-full.timelines.test.ts b/tests/twitter-client.json-full.timelines.test.ts index ad02021..4b10b62 100644 --- a/tests/twitter-client.json-full.timelines.test.ts +++ b/tests/twitter-client.json-full.timelines.test.ts @@ -183,4 +183,61 @@ describe('TwitterClient timelines includeRaw', () => { expect(result.tweets?.[0]._raw).toBeDefined(); expect(result.tweets?.[0]._raw?.entities?.hashtags?.[0].text).toBe('like'); }); + + it('includes _raw for list timeline when includeRaw is true', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + list: { + tweets_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '777', + legacy: { + full_text: 'from list', + created_at: '2024-01-01T00:00:00Z', + }, + core: { + user_results: { + result: { legacy: { screen_name: 'listmember', name: 'List Member' } }, + }, + }, + entities: { + hashtags: [{ text: 'listtweet' }], + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getListTimelineQueryIds = async () => ['test']; + + const result = await client.getListTimeline('12345', 1, { includeRaw: true }); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(1); + expect(result.tweets?.[0]._raw).toBeDefined(); + expect(result.tweets?.[0]._raw?.entities?.hashtags?.[0].text).toBe('listtweet'); + }); }); diff --git a/tests/twitter-client.lists.test.ts b/tests/twitter-client.lists.test.ts index 7cd0b0a..56dacad 100644 --- a/tests/twitter-client.lists.test.ts +++ b/tests/twitter-client.lists.test.ts @@ -3,22 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TwitterClient } from '../src/lib/twitter-client.js'; -import { validCookies } from './twitter-client-fixtures.js'; +import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; const originalFetch = global.fetch; -type TwitterClientPrivate = TwitterClient & { - getCurrentUser: () => Promise<{ - success: boolean; - user?: { id: string; username: string; name: string }; - error?: string; - }>; - getListOwnershipsQueryIds: () => Promise; - getListMembershipsQueryIds: () => Promise; - getListTimelineQueryIds: () => Promise; - refreshQueryIds: () => Promise; -}; - afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); @@ -239,6 +227,193 @@ describe('TwitterClient lists', () => { expect(result.success).toBe(false); expect(result.error).toContain('Rate limit exceeded'); }); + + 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', () => { @@ -298,6 +473,63 @@ describe('TwitterClient lists', () => { 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', () => {