@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## 0.4.2 — Unreleased
|
## 0.4.2 — Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `likes` command to list your liked tweets (thanks @swairshah).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Query ID updater now tracks the Likes GraphQL operation.
|
||||||
|
|
||||||
## 0.4.1 — 2025-12-31
|
## 0.4.1 — 2025-12-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -48,10 +48,13 @@ bird search "from:steipete" -n 5
|
|||||||
bird mentions -n 5
|
bird mentions -n 5
|
||||||
bird mentions --user @steipete -n 5
|
bird mentions --user @steipete -n 5
|
||||||
|
|
||||||
# Bookmarks
|
# Bookmarks
|
||||||
bird bookmarks -n 5
|
bird bookmarks -n 5
|
||||||
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
|
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
|
||||||
|
|
||||||
|
# Likes
|
||||||
|
bird likes -n 5
|
||||||
|
|
||||||
# Refresh GraphQL query IDs cache (no rebuild)
|
# Refresh GraphQL query IDs cache (no rebuild)
|
||||||
bird query-ids --fresh
|
bird query-ids --fresh
|
||||||
```
|
```
|
||||||
@@ -69,6 +72,7 @@ bird query-ids --fresh
|
|||||||
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
||||||
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
||||||
- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
|
- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
|
||||||
|
- `bird likes [-n count] [--json]` — list your liked tweets.
|
||||||
- `bird whoami` — print which Twitter account your cookies belong to.
|
- `bird whoami` — print which Twitter account your cookies belong to.
|
||||||
- `bird check` — show which credentials are available and where they were sourced from.
|
- `bird check` — show which credentials are available and where they were sourced from.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const TARGET_OPERATIONS = [
|
|||||||
'TweetDetail',
|
'TweetDetail',
|
||||||
'SearchTimeline',
|
'SearchTimeline',
|
||||||
'Bookmarks',
|
'Bookmarks',
|
||||||
|
'Likes',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type OperationName = (typeof TARGET_OPERATIONS)[number];
|
type OperationName = (typeof TARGET_OPERATIONS)[number];
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const KNOWN_COMMANDS = new Set([
|
|||||||
'search',
|
'search',
|
||||||
'mentions',
|
'mentions',
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
|
'likes',
|
||||||
'help',
|
'help',
|
||||||
'whoami',
|
'whoami',
|
||||||
'check',
|
'check',
|
||||||
@@ -348,6 +349,7 @@ program
|
|||||||
'SearchTimeline',
|
'SearchTimeline',
|
||||||
'UserArticlesTweets',
|
'UserArticlesTweets',
|
||||||
'Bookmarks',
|
'Bookmarks',
|
||||||
|
'Likes',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (cmdOpts.fresh) {
|
if (cmdOpts.fresh) {
|
||||||
@@ -751,6 +753,39 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Likes command - get user's liked tweets
|
||||||
|
program
|
||||||
|
.command('likes')
|
||||||
|
.description('Get your liked tweets')
|
||||||
|
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||||
|
|
||||||
|
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.error(`${p('warn')}${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
|
console.error(`${p('err')}Missing required credentials`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
|
const result = await client.getLikes(count);
|
||||||
|
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
|
||||||
|
} else {
|
||||||
|
console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Whoami command - show the logged-in account
|
// Whoami command - show the logged-in account
|
||||||
program
|
program
|
||||||
.command('whoami')
|
.command('whoami')
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
|
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
||||||
|
"Likes": "JR2gceKucIKcVNB_9JkhsA"
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-3
@@ -28,6 +28,7 @@ const FALLBACK_QUERY_IDS = {
|
|||||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||||
|
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
||||||
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -1871,10 +1872,10 @@ export class TwitterClient {
|
|||||||
return { success: true, tweets: thread };
|
return { success: true, tweets: thread };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildBookmarksFeatures(): Record<string, boolean> {
|
// Shared timeline features for likes/bookmarks-style timelines.
|
||||||
|
private buildTimelineFeatures(): Record<string, boolean> {
|
||||||
return {
|
return {
|
||||||
...this.buildSearchFeatures(),
|
...this.buildSearchFeatures(),
|
||||||
graphql_timeline_v2_bookmark_timeline: true,
|
|
||||||
blue_business_profile_image_shape_enabled: true,
|
blue_business_profile_image_shape_enabled: true,
|
||||||
responsive_web_text_conversations_enabled: false,
|
responsive_web_text_conversations_enabled: false,
|
||||||
tweetypie_unmention_optimization_enabled: true,
|
tweetypie_unmention_optimization_enabled: true,
|
||||||
@@ -1886,6 +1887,17 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildBookmarksFeatures(): Record<string, boolean> {
|
||||||
|
return {
|
||||||
|
...this.buildTimelineFeatures(),
|
||||||
|
graphql_timeline_v2_bookmark_timeline: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildLikesFeatures(): Record<string, boolean> {
|
||||||
|
return this.buildTimelineFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
private async getBookmarksQueryIds(): Promise<string[]> {
|
private async getBookmarksQueryIds(): Promise<string[]> {
|
||||||
const primary = await this.getQueryId('Bookmarks');
|
const primary = await this.getQueryId('Bookmarks');
|
||||||
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
|
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
|
||||||
@@ -1993,6 +2005,118 @@ export class TwitterClient {
|
|||||||
|
|
||||||
return { success: false, error: firstAttempt.error };
|
return { success: false, error: firstAttempt.error };
|
||||||
}
|
}
|
||||||
|
private async getLikesQueryIds(): Promise<string[]> {
|
||||||
|
const primary = await this.getQueryId('Likes');
|
||||||
|
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authenticated user's liked tweets
|
||||||
|
*/
|
||||||
|
async getLikes(count = 20): Promise<SearchResult> {
|
||||||
|
const userResult = await this.getCurrentUser();
|
||||||
|
if (!userResult.success || !userResult.user) {
|
||||||
|
return { success: false, error: userResult.error ?? 'Could not determine current user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
userId: userResult.user.id,
|
||||||
|
count,
|
||||||
|
includePromotedContent: false,
|
||||||
|
withClientEventToken: false,
|
||||||
|
withBirdwatchNotes: false,
|
||||||
|
withVoice: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = this.buildLikesFeatures();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
variables: JSON.stringify(variables),
|
||||||
|
features: JSON.stringify(features),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tryOnce = async () => {
|
||||||
|
let lastError: string | undefined;
|
||||||
|
let had404 = false;
|
||||||
|
const queryIds = await this.getLikesQueryIds();
|
||||||
|
|
||||||
|
for (const queryId of queryIds) {
|
||||||
|
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithTimeout(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
had404 = true;
|
||||||
|
lastError = `HTTP ${response.status}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
data?: {
|
||||||
|
user?: {
|
||||||
|
result?: {
|
||||||
|
timeline?: {
|
||||||
|
timeline?: {
|
||||||
|
instructions?: Array<{
|
||||||
|
entries?: Array<{
|
||||||
|
content?: {
|
||||||
|
itemContent?: {
|
||||||
|
tweet_results?: {
|
||||||
|
result?: GraphqlTweetResult;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||||
|
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||||
|
|
||||||
|
return { success: true as const, tweets, had404 };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstAttempt = await tryOnce();
|
||||||
|
if (firstAttempt.success) {
|
||||||
|
return { success: true, tweets: firstAttempt.tweets };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstAttempt.had404) {
|
||||||
|
await this.refreshQueryIds();
|
||||||
|
const secondAttempt = await tryOnce();
|
||||||
|
if (secondAttempt.success) {
|
||||||
|
return { success: true, tweets: secondAttempt.tweets };
|
||||||
|
}
|
||||||
|
return { success: false, error: secondAttempt.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: firstAttempt.error };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the authenticated user's bookmark folder timeline
|
* Get the authenticated user's bookmark folder timeline
|
||||||
@@ -2022,7 +2146,7 @@ export class TwitterClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const queryId of queryIds) {
|
for (const queryId of queryIds) {
|
||||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params}`;
|
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(url, {
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ type ResponseLike = {
|
|||||||
|
|
||||||
type TwitterClientApiPrivate = TwitterClient & {
|
type TwitterClientApiPrivate = TwitterClient & {
|
||||||
getBookmarksQueryIds: () => Promise<string[]>;
|
getBookmarksQueryIds: () => Promise<string[]>;
|
||||||
|
getLikesQueryIds: () => Promise<string[]>;
|
||||||
|
getCurrentUser: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
user?: { id: string; username: string; name: string };
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
getBookmarkFolderQueryIds: () => Promise<string[]>;
|
getBookmarkFolderQueryIds: () => Promise<string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,7 +211,90 @@ describe('TwitterClient API coverage', () => {
|
|||||||
expect(result.error).toBe('HTTP 404');
|
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', () => {
|
describe('bookmark folder error paths', () => {
|
||||||
it('returns an error for non-ok responses', async () => {
|
it('returns an error for non-ok responses', async () => {
|
||||||
const mockFetch = vi
|
const mockFetch = vi
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ describe('TwitterClient', () => {
|
|||||||
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
|
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
|
||||||
source: 'test',
|
source: 'test',
|
||||||
};
|
};
|
||||||
|
type TwitterClientPrivate = TwitterClient & {
|
||||||
|
getCurrentUser: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
user?: { id: string; username: string; name: string };
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
getLikesQueryIds: () => Promise<string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
@@ -1144,6 +1152,101 @@ describe('TwitterClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('bookmark folders', () => {
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
@@ -1291,7 +1394,6 @@ describe('TwitterClient', () => {
|
|||||||
expect(secondVars.count).toBeUndefined();
|
expect(secondVars.count).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('conversation helpers', () => {
|
describe('conversation helpers', () => {
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user