chore: merge origin/main

This commit is contained in:
Peter Steinberger
2026-01-01 11:31:29 +01:00
10 changed files with 935 additions and 18 deletions
+27
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
import { extractTweetId } from '../src/lib/extract-tweet-id.js';
describe('CLI utilities', () => {
@@ -29,4 +30,30 @@ describe('CLI utilities', () => {
expect(extractTweetId(url)).toBe('1234567890123456789');
});
});
describe('extractBookmarkFolderId', () => {
it('should extract ID from x.com bookmarks URL', () => {
const url = 'https://x.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should extract ID from twitter.com bookmarks URL', () => {
const url = 'https://twitter.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should accept a numeric ID as-is', () => {
const id = '1976792203235119344';
expect(extractBookmarkFolderId(id)).toBe('1976792203235119344');
});
it('should return null for invalid values', () => {
expect(extractBookmarkFolderId('not-an-id')).toBeNull();
});
it('should return null for folder_id query URLs', () => {
const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344';
expect(extractBookmarkFolderId(url)).toBeNull();
});
});
});
+153
View File
@@ -19,6 +19,13 @@ type TwitterClientApiPrivate = TwitterClient & {
getBookmarksQueryIds: () => Promise<string[]>;
getFollowingQueryIds: () => Promise<string[]>;
getFollowersQueryIds: () => Promise<string[]>;
getLikesQueryIds: () => Promise<string[]>;
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getBookmarkFolderQueryIds: () => Promise<string[]>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
@@ -263,6 +270,152 @@ describe('TwitterClient API coverage', () => {
const result = await client.getFollowers('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('likes error paths', () => {
const stubCurrentUser = async () => ({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => [];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching likes');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmark folder error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => [];
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmark folder');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
+339 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TweetData, TwitterClient } from '../src/lib/twitter-client.js';
describe('TwitterClient', () => {
const originalFetch = global.fetch;
@@ -9,6 +9,14 @@ describe('TwitterClient', () => {
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type TwitterClientPrivate = TwitterClient & {
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getLikesQueryIds: () => Promise<string[]>;
};
afterEach(() => {
global.fetch = originalFetch;
@@ -952,6 +960,95 @@ describe('TwitterClient', () => {
});
});
describe('quoted tweets', () => {
const makeTweetResult = (
id: string,
text: string,
username = `user${id}`,
name = `User ${id}`,
): Record<string, unknown> => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: `u${id}`,
legacy: { screen_name: username, name },
},
},
},
});
it('includes one level of quoted tweet by default', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet?.id).toBe('2');
expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined();
});
it('honors quoteDepth = 0', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet).toBeUndefined();
});
it('recurses when quoteDepth > 1', () => {
const quoted2 = makeTweetResult('3', 'quoted2');
const quoted1 = makeTweetResult('2', 'quoted1');
quoted1.quoted_status_result = { result: quoted2 };
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted1 };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet?.id).toBe('2');
expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3');
expect(mapped?.quotedTweet?.quotedTweet?.quotedTweet).toBeUndefined();
});
it('unwraps quoted tweet visibility wrappers', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = {
result: {
__typename: 'TweetWithVisibilityResults',
tweet: quoted,
},
};
const client = new TwitterClient({ cookies: validCookies });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet?.id).toBe('2');
});
});
describe('search', () => {
let mockFetch: ReturnType<typeof vi.fn>;
@@ -1332,7 +1429,248 @@ describe('TwitterClient', () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe('likes', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches likes and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Likes?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.userId).toBe('42');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined();
});
it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' });
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('no user');
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('bookmark folders', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmark folder timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('9');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/BookmarkFolderTimeline?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.bookmark_collection_id).toBe('123');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
it('retries without count when API rejects the count variable', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Variable "$count" is not defined by operation' }],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise<string[]> };
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
const firstVars = JSON.parse(
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string,
);
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
);
expect(firstVars.count).toBe(2);
expect(secondVars.count).toBeUndefined();
});
});
describe('conversation helpers', () => {
let mockFetch: ReturnType<typeof vi.fn>;