test(lists): improve test coverage and extract list ID parsing
- Extract extractListId to src/lib/extract-list-id.ts with proper tests - Add json-full/includeRaw test for list timeline - Add 404 retry tests for getOwnedLists and getListMemberships - Add tests for skipping invalid list entries and missing owner handling - Use shared TwitterClientPrivate type from fixtures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
committed by
Peter Steinberger
co-authored by
Claude Opus 4.5
parent
7818440d65
commit
59e827e153
+1
-22
@@ -3,31 +3,10 @@
|
|||||||
|
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import type { CliContext } from '../cli/shared.js';
|
import type { CliContext } from '../cli/shared.js';
|
||||||
|
import { extractListId } from '../lib/extract-list-id.js';
|
||||||
import type { TwitterList } from '../lib/twitter-client.js';
|
import type { TwitterList } from '../lib/twitter-client.js';
|
||||||
import { TwitterClient } 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 {
|
function printLists(lists: TwitterList[], ctx: CliContext): void {
|
||||||
if (lists.length === 0) {
|
if (lists.length === 0) {
|
||||||
console.log('No lists found.');
|
console.log('No lists found.');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
|
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';
|
import { extractTweetId } from '../src/lib/extract-tweet-id.js';
|
||||||
|
|
||||||
describe('CLI utilities', () => {
|
describe('CLI utilities', () => {
|
||||||
@@ -56,4 +57,46 @@ describe('CLI utilities', () => {
|
|||||||
expect(extractBookmarkFolderId(url)).toBeNull();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,4 +14,8 @@ export type TwitterClientPrivate = TwitterClient & {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
getLikesQueryIds: () => Promise<string[]>;
|
getLikesQueryIds: () => Promise<string[]>;
|
||||||
|
getListTimelineQueryIds: () => Promise<string[]>;
|
||||||
|
getListOwnershipsQueryIds: () => Promise<string[]>;
|
||||||
|
getListMembershipsQueryIds: () => Promise<string[]>;
|
||||||
|
refreshQueryIds: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -183,4 +183,61 @@ describe('TwitterClient timelines includeRaw', () => {
|
|||||||
expect(result.tweets?.[0]._raw).toBeDefined();
|
expect(result.tweets?.[0]._raw).toBeDefined();
|
||||||
expect(result.tweets?.[0]._raw?.entities?.hashtags?.[0].text).toBe('like');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,22 +3,10 @@
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { TwitterClient } from '../src/lib/twitter-client.js';
|
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;
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
type TwitterClientPrivate = TwitterClient & {
|
|
||||||
getCurrentUser: () => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
user?: { id: string; username: string; name: string };
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
getListOwnershipsQueryIds: () => Promise<string[]>;
|
|
||||||
getListMembershipsQueryIds: () => Promise<string[]>;
|
|
||||||
getListTimelineQueryIds: () => Promise<string[]>;
|
|
||||||
refreshQueryIds: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -239,6 +227,193 @@ describe('TwitterClient lists', () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('Rate limit exceeded');
|
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', () => {
|
describe('getListMemberships', () => {
|
||||||
@@ -298,6 +473,63 @@ describe('TwitterClient lists', () => {
|
|||||||
expect(result.lists?.[0].name).toBe('Member List');
|
expect(result.lists?.[0].name).toBe('Member List');
|
||||||
expect(result.lists?.[0].owner?.username).toBe('otheruser');
|
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', () => {
|
describe('getListTimeline', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user