test: split following/likes tests (#33) (thanks @VACInc)
This commit is contained in:
+3
-1
@@ -14,6 +14,9 @@
|
|||||||
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
|
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
|
||||||
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
|
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
|
||||||
|
|
||||||
## 0.6.0 — 2026-01-05
|
## 0.6.0 — 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -22,7 +25,6 @@
|
|||||||
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
|
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
|
||||||
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
|
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
|
||||||
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
|
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
|
||||||
- `following` now paginates with cursors and surfaces partial-page errors (#33) — thanks @VACInc.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
|
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
|
||||||
|
|||||||
@@ -1,660 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { TwitterClient } from '../src/lib/twitter-client.js';
|
|
||||||
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
|
|
||||||
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TwitterClient following/followers', () => {
|
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
const makeUserResult = (id: string, username: string, name = username) => ({
|
|
||||||
__typename: 'User',
|
|
||||||
rest_id: id,
|
|
||||||
is_blue_verified: true,
|
|
||||||
legacy: {
|
|
||||||
screen_name: username,
|
|
||||||
name,
|
|
||||||
description: `bio-${id}`,
|
|
||||||
followers_count: 10,
|
|
||||||
friends_count: 5,
|
|
||||||
profile_image_url_https: `https://example.com/${id}.jpg`,
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch = vi.fn();
|
|
||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches following users and filters invalid entries', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
type: 'TimelineAddEntries',
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: makeUserResult('1', 'alpha', 'Alpha'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: { __typename: 'User', rest_id: '2' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: { __typename: 'TimelineUser' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
|
||||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
|
||||||
|
|
||||||
const result = await client.getFollowing('123', 2);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.users?.length).toBe(1);
|
|
||||||
expect(result.users?.[0].username).toBe('alpha');
|
|
||||||
expect(result.users?.[0].followersCount).toBe(10);
|
|
||||||
expect(result.users?.[0].followingCount).toBe(5);
|
|
||||||
expect(result.users?.[0].isBlueVerified).toBe(true);
|
|
||||||
expect(result.users?.[0].profileImageUrl).toBe('https://example.com/1.jpg');
|
|
||||||
expect(result.users?.[0].createdAt).toBe('2024-01-01T00:00:00Z');
|
|
||||||
const [url] = mockFetch.mock.calls[0];
|
|
||||||
expect(String(url)).toContain('/Following?');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches followers and unwraps visibility results', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: {
|
|
||||||
__typename: 'UserWithVisibilityResults',
|
|
||||||
user: makeUserResult('9', 'vis', 'Visible'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
|
|
||||||
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
|
||||||
|
|
||||||
const result = await client.getFollowers('123', 1);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.users?.[0].username).toBe('vis');
|
|
||||||
const [url] = mockFetch.mock.calls[0];
|
|
||||||
expect(String(url)).toContain('/Followers?');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes query IDs after 404s', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' }).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: makeUserResult('1', 'alpha', 'Alpha'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & {
|
|
||||||
getFollowingQueryIds: () => Promise<string[]>;
|
|
||||||
refreshQueryIds: () => Promise<void>;
|
|
||||||
};
|
|
||||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
|
||||||
let refreshed = false;
|
|
||||||
clientPrivate.refreshQueryIds = async () => {
|
|
||||||
refreshed = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await client.getFollowing('123', 1);
|
|
||||||
|
|
||||||
expect(refreshed).toBe(true);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to REST following list after repeated 404s', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' })
|
|
||||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'still nope' })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
id_str: '1',
|
|
||||||
screen_name: 'alpha',
|
|
||||||
name: 'Alpha',
|
|
||||||
description: 'bio-1',
|
|
||||||
followers_count: 10,
|
|
||||||
friends_count: 5,
|
|
||||||
verified: true,
|
|
||||||
profile_image_url_https: 'https://example.com/1.jpg',
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
|
||||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
|
||||||
|
|
||||||
const result = await client.getFollowing('123', 1);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.users?.[0].username).toBe('alpha');
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
const urls = mockFetch.mock.calls.map((call) => String(call[0]));
|
|
||||||
expect(urls[0]).toContain('/Following?');
|
|
||||||
expect(urls[1]).toContain('/Following?');
|
|
||||||
expect(urls[2]).toContain('/friends/list.json?');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to REST followers list after repeated 404s', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' })
|
|
||||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'still nope' })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
id_str: '1',
|
|
||||||
screen_name: 'alpha',
|
|
||||||
name: 'Alpha',
|
|
||||||
description: 'bio-1',
|
|
||||||
followers_count: 10,
|
|
||||||
friends_count: 5,
|
|
||||||
verified: true,
|
|
||||||
profile_image_url_https: 'https://example.com/1.jpg',
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
|
|
||||||
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
|
||||||
|
|
||||||
const result = await client.getFollowers('123', 1);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.users?.[0].username).toBe('alpha');
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
const urls = mockFetch.mock.calls.map((call) => String(call[0]));
|
|
||||||
expect(urls[0]).toContain('/Followers?');
|
|
||||||
expect(urls[1]).toContain('/Followers?');
|
|
||||||
expect(urls[2]).toContain('/followers/list.json?');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes cursor parameter to following API and returns nextCursor', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
type: 'TimelineAddEntries',
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: makeUserResult('1', 'alpha', 'Alpha'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
cursorType: 'Bottom',
|
|
||||||
value: 'next-cursor-abc123',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
|
||||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
|
||||||
|
|
||||||
const result = await client.getFollowing('123', 20, 'prev-cursor-xyz');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.users?.length).toBe(1);
|
|
||||||
expect(result.nextCursor).toBe('next-cursor-abc123');
|
|
||||||
|
|
||||||
// Verify cursor was passed in the request
|
|
||||||
const [url] = mockFetch.mock.calls[0];
|
|
||||||
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
|
|
||||||
expect(parsedVars.cursor).toBe('prev-cursor-xyz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes cursor parameter to followers API and returns nextCursor', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: makeUserResult('9', 'beta', 'Beta'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
cursorType: 'Bottom',
|
|
||||||
value: 'followers-next-cursor',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
|
|
||||||
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
|
||||||
|
|
||||||
const result = await client.getFollowers('456', 50, 'my-cursor');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.users?.[0].username).toBe('beta');
|
|
||||||
expect(result.nextCursor).toBe('followers-next-cursor');
|
|
||||||
|
|
||||||
// Verify cursor was passed in the request
|
|
||||||
const [url] = mockFetch.mock.calls[0];
|
|
||||||
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
|
|
||||||
expect(parsedVars.cursor).toBe('my-cursor');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined nextCursor when no cursor in response', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
user_results: {
|
|
||||||
result: makeUserResult('1', 'only', 'Only'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
|
||||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
|
||||||
|
|
||||||
const result = await client.getFollowing('123', 20);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.nextCursor).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TwitterClient 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('paginates likes when count exceeds the first page', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
tweet_results: {
|
|
||||||
result: {
|
|
||||||
rest_id: '1',
|
|
||||||
legacy: {
|
|
||||||
full_text: 'liked page 1',
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
reply_count: 0,
|
|
||||||
retweet_count: 0,
|
|
||||||
favorite_count: 0,
|
|
||||||
conversation_id_str: '1',
|
|
||||||
},
|
|
||||||
core: {
|
|
||||||
user_results: {
|
|
||||||
result: {
|
|
||||||
rest_id: 'u1',
|
|
||||||
legacy: { screen_name: 'root', name: 'Root' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
cursorType: 'Bottom',
|
|
||||||
value: 'cursor-1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.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 page 2',
|
|
||||||
created_at: '2024-01-02T00: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(3);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2']);
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
const secondVars = JSON.parse(
|
|
||||||
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
|
|
||||||
);
|
|
||||||
expect(secondVars.cursor).toBe('cursor-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -43,9 +43,8 @@ describe('TwitterClient following pagination', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('paginates following results with cursor', async () => {
|
it('returns nextCursor from the response', async () => {
|
||||||
mockFetch
|
mockFetch.mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -68,27 +67,6 @@ describe('TwitterClient following pagination', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
timeline: {
|
|
||||||
timeline: {
|
|
||||||
instructions: [
|
|
||||||
{
|
|
||||||
entries: [makeUserEntry('2', 'bob', 'Bob')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
@@ -99,16 +77,14 @@ describe('TwitterClient following pagination', () => {
|
|||||||
const result = await client.getFollowing('user-id', 3);
|
const result = await client.getFollowing('user-id', 3);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.users).toHaveLength(2);
|
expect(result.users).toHaveLength(1);
|
||||||
expect(result.users?.[0].username).toBe('alice');
|
expect(result.users?.[0].username).toBe('alice');
|
||||||
expect(result.users?.[1].username).toBe('bob');
|
expect(result.nextCursor).toBe('cursor-2');
|
||||||
expect(result.nextCursor).toBeUndefined();
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error when a later page fails', async () => {
|
it('passes cursor parameter to following API and returns nextCursor', async () => {
|
||||||
mockFetch
|
mockFetch.mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -131,8 +107,59 @@ describe('TwitterClient following pagination', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
.mockResolvedValueOnce({
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => ['q1'];
|
||||||
|
clientPrivate.getFollowingViaRest = vi.fn();
|
||||||
|
|
||||||
|
const result = await client.getFollowing('user-id', 20, 'prev-cursor-xyz');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.nextCursor).toBe('cursor-2');
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
|
||||||
|
expect(parsedVars.cursor).toBe('prev-cursor-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined nextCursor when no cursor in response', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [makeUserEntry('1', 'alice', 'Alice')],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientPrivate;
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => ['q1'];
|
||||||
|
clientPrivate.getFollowingViaRest = vi.fn();
|
||||||
|
|
||||||
|
const result = await client.getFollowing('user-id', 20);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.nextCursor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error for non-ok responses', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
text: async () => 'boom',
|
text: async () => 'boom',
|
||||||
@@ -146,8 +173,8 @@ describe('TwitterClient following pagination', () => {
|
|||||||
const result = await client.getFollowing('user-id', 3);
|
const result = await client.getFollowing('user-id', 3);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.users).toHaveLength(1);
|
|
||||||
expect(result.error).toContain('500');
|
expect(result.error).toContain('500');
|
||||||
expect(result.nextCursor).toBe('cursor-2');
|
expect(result.users).toBeUndefined();
|
||||||
|
expect(result.nextCursor).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
import { validCookies } from './twitter-client-fixtures.js';
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient following/followers', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const makeUserResult = (id: string, username: string, name = username) => ({
|
||||||
|
__typename: 'User',
|
||||||
|
rest_id: id,
|
||||||
|
is_blue_verified: true,
|
||||||
|
legacy: {
|
||||||
|
screen_name: username,
|
||||||
|
name,
|
||||||
|
description: `bio-${id}`,
|
||||||
|
followers_count: 10,
|
||||||
|
friends_count: 5,
|
||||||
|
profile_image_url_https: `https://example.com/${id}.jpg`,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches following users and filters invalid entries', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
type: 'TimelineAddEntries',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: makeUserResult('1', 'alpha', 'Alpha'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: { __typename: 'User', rest_id: '2' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: { __typename: 'TimelineUser' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||||
|
|
||||||
|
const result = await client.getFollowing('123', 2);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users?.length).toBe(1);
|
||||||
|
expect(result.users?.[0].username).toBe('alpha');
|
||||||
|
expect(result.users?.[0].followersCount).toBe(10);
|
||||||
|
expect(result.users?.[0].followingCount).toBe(5);
|
||||||
|
expect(result.users?.[0].isBlueVerified).toBe(true);
|
||||||
|
expect(result.users?.[0].profileImageUrl).toBe('https://example.com/1.jpg');
|
||||||
|
expect(result.users?.[0].createdAt).toBe('2024-01-01T00:00:00Z');
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(String(url)).toContain('/Following?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches followers and unwraps visibility results', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
__typename: 'UserWithVisibilityResults',
|
||||||
|
user: makeUserResult('9', 'vis', 'Visible'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
|
||||||
|
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
||||||
|
|
||||||
|
const result = await client.getFollowers('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users?.[0].username).toBe('vis');
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(String(url)).toContain('/Followers?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes query IDs after 404s', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' }).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: makeUserResult('1', 'alpha', 'Alpha'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClient & {
|
||||||
|
getFollowingQueryIds: () => Promise<string[]>;
|
||||||
|
refreshQueryIds: () => Promise<void>;
|
||||||
|
};
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||||
|
let refreshed = false;
|
||||||
|
clientPrivate.refreshQueryIds = async () => {
|
||||||
|
refreshed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await client.getFollowing('123', 1);
|
||||||
|
|
||||||
|
expect(refreshed).toBe(true);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to REST following list after repeated 404s', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'still nope' })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id_str: '1',
|
||||||
|
screen_name: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
description: 'bio-1',
|
||||||
|
followers_count: 10,
|
||||||
|
friends_count: 5,
|
||||||
|
verified: true,
|
||||||
|
profile_image_url_https: 'https://example.com/1.jpg',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||||
|
|
||||||
|
const result = await client.getFollowing('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users?.[0].username).toBe('alpha');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
const urls = mockFetch.mock.calls.map((call) => String(call[0]));
|
||||||
|
expect(urls[0]).toContain('/Following?');
|
||||||
|
expect(urls[1]).toContain('/Following?');
|
||||||
|
expect(urls[2]).toContain('/friends/list.json?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to REST followers list after repeated 404s', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'still nope' })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id_str: '1',
|
||||||
|
screen_name: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
description: 'bio-1',
|
||||||
|
followers_count: 10,
|
||||||
|
friends_count: 5,
|
||||||
|
verified: true,
|
||||||
|
profile_image_url_https: 'https://example.com/1.jpg',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
|
||||||
|
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
||||||
|
|
||||||
|
const result = await client.getFollowers('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users?.[0].username).toBe('alpha');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
const urls = mockFetch.mock.calls.map((call) => String(call[0]));
|
||||||
|
expect(urls[0]).toContain('/Followers?');
|
||||||
|
expect(urls[1]).toContain('/Followers?');
|
||||||
|
expect(urls[2]).toContain('/followers/list.json?');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
describe('TwitterClient 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('paginates likes when count exceeds the first page', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '1',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'liked page 1',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
reply_count: 0,
|
||||||
|
retweet_count: 0,
|
||||||
|
favorite_count: 0,
|
||||||
|
conversation_id_str: '1',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: 'u1',
|
||||||
|
legacy: { screen_name: 'root', name: 'Root' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
cursorType: 'Bottom',
|
||||||
|
value: 'cursor-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.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 page 2',
|
||||||
|
created_at: '2024-01-02T00: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(3);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2']);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const secondVars = JSON.parse(
|
||||||
|
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
|
||||||
|
);
|
||||||
|
expect(secondVars.cursor).toBe('cursor-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user