From dec78547fe538de9d55a51b1d759eb85a3426cda Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 00:46:32 +0000 Subject: [PATCH] test: split following/likes tests (#33) (thanks @VACInc) --- CHANGELOG.md | 4 +- tests/twitter-client.following-likes.test.ts | 660 ------------------ ...witter-client.following-pagination.test.ts | 175 +++-- tests/twitter-client.following.test.ts | 283 ++++++++ tests/twitter-client.likes.test.ts | 232 ++++++ 5 files changed, 619 insertions(+), 735 deletions(-) delete mode 100644 tests/twitter-client.following-likes.test.ts create mode 100644 tests/twitter-client.following.test.ts create mode 100644 tests/twitter-client.likes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 14bf49f..6526f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ - 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. +### Tests +- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc. + ## 0.6.0 — 2026-01-05 ### Added @@ -22,7 +25,6 @@ - Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold - Bookmarks can resume pagination from a cursor (#26) — thanks @leonho - `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky. -- `following` now paginates with cursors and surfaces partial-page errors (#33) — thanks @VACInc. ### Changed - Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`). diff --git a/tests/twitter-client.following-likes.test.ts b/tests/twitter-client.following-likes.test.ts deleted file mode 100644 index 606d47f..0000000 --- a/tests/twitter-client.following-likes.test.ts +++ /dev/null @@ -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; - - 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 }; - 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 }; - 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; - refreshQueryIds: () => Promise; - }; - 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 }; - 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 }; - 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 }; - 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 }; - 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 }; - 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; - - 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(); - }); -}); diff --git a/tests/twitter-client.following-pagination.test.ts b/tests/twitter-client.following-pagination.test.ts index d38112f..ef076f0 100644 --- a/tests/twitter-client.following-pagination.test.ts +++ b/tests/twitter-client.following-pagination.test.ts @@ -43,53 +43,31 @@ describe('TwitterClient following pagination', () => { vi.restoreAllMocks(); }); - it('paginates following results with cursor', async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { + it('returns nextCursor from the response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { timeline: { - timeline: { - instructions: [ - { - entries: [ - makeUserEntry('1', 'alice', 'Alice'), - { content: { cursorType: 'Bottom', value: 'cursor-2' } }, - ], - }, - ], - }, + instructions: [ + { + entries: [ + makeUserEntry('1', 'alice', 'Alice'), + { content: { cursorType: 'Bottom', value: 'cursor-2' } }, + ], + }, + ], }, }, }, }, - }), - }) - .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 clientPrivate = client as unknown as TwitterClientPrivate; @@ -99,44 +77,93 @@ describe('TwitterClient following pagination', () => { const result = await client.getFollowing('user-id', 3); 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?.[1].username).toBe('bob'); - expect(result.nextCursor).toBeUndefined(); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.nextCursor).toBe('cursor-2'); + expect(mockFetch).toHaveBeenCalledTimes(1); }); - it('returns error when a later page fails', async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - data: { - user: { - result: { + it('passes cursor parameter to following API and returns nextCursor', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { timeline: { - timeline: { - instructions: [ - { - entries: [ - makeUserEntry('1', 'alice', 'Alice'), - { content: { cursorType: 'Bottom', value: 'cursor-2' } }, - ], - }, - ], - }, + instructions: [ + { + entries: [ + makeUserEntry('1', 'alice', 'Alice'), + { content: { cursorType: 'Bottom', value: 'cursor-2' } }, + ], + }, + ], }, }, }, }, - }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - text: async () => 'boom', - }); + }, + }), + }); + + 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, + status: 500, + text: async () => 'boom', + }); const client = new TwitterClient({ cookies: validCookies }); const clientPrivate = client as unknown as TwitterClientPrivate; @@ -146,8 +173,8 @@ describe('TwitterClient following pagination', () => { const result = await client.getFollowing('user-id', 3); expect(result.success).toBe(false); - expect(result.users).toHaveLength(1); expect(result.error).toContain('500'); - expect(result.nextCursor).toBe('cursor-2'); + expect(result.users).toBeUndefined(); + expect(result.nextCursor).toBeUndefined(); }); }); diff --git a/tests/twitter-client.following.test.ts b/tests/twitter-client.following.test.ts new file mode 100644 index 0000000..f9a25a3 --- /dev/null +++ b/tests/twitter-client.following.test.ts @@ -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; + + 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 }; + 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 }; + 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; + refreshQueryIds: () => Promise; + }; + 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 }; + 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 }; + 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?'); + }); +}); diff --git a/tests/twitter-client.likes.test.ts b/tests/twitter-client.likes.test.ts new file mode 100644 index 0000000..afe9a12 --- /dev/null +++ b/tests/twitter-client.likes.test.ts @@ -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; + + 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(); + }); +});