Merge pull request #7 from tylerseymour/bookmark-collection-selection

feat: add folder support for bookmarks command
This commit is contained in:
Peter Steinberger
2026-01-01 09:15:28 +01:00
committed by GitHub
8 changed files with 389 additions and 5 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();
});
});
});
+64
View File
@@ -17,6 +17,7 @@ type ResponseLike = {
type TwitterClientApiPrivate = TwitterClient & {
getBookmarksQueryIds: () => Promise<string[]>;
getBookmarkFolderQueryIds: () => Promise<string[]>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
@@ -204,4 +205,67 @@ describe('TwitterClient API coverage', () => {
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');
});
});
});
+148
View File
@@ -1144,6 +1144,154 @@ describe('TwitterClient', () => {
});
});
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>;