Files
bird/tests/twitter-client.home-timeline.test.ts
yuta 826de66d78
CI / test (push) Successful in 9m58s
Add home timeline pagination flags
2026-06-24 20:17:02 +09:00

460 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
// Realistic fixture based on actual Twitter API response structure
const makeHomeTimelineResponse = (tweetId: string, text: string, username: string) => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
__typename: 'Tweet',
rest_id: tweetId,
legacy: {
full_text: text,
created_at: 'Mon Jan 06 00:00:00 +0000 2025',
reply_count: 5,
retweet_count: 10,
favorite_count: 25,
conversation_id_str: tweetId,
},
core: {
user_results: {
result: {
__typename: 'User',
rest_id: 'u123',
legacy: {
screen_name: username,
name: `${username} Name`,
},
},
},
},
},
},
},
},
},
],
},
],
},
},
},
});
describe('TwitterClient home timeline', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getHomeTimeline', () => {
it('should return tweets from For You feed', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => makeHomeTimelineResponse('123456', 'Hello from For You feed!', 'testuser'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0].id).toBe('123456');
expect(result.tweets?.[0].text).toBe('Hello from For You feed!');
expect(result.tweets?.[0].author.username).toBe('testuser');
});
it('should return error on non-ok response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
text: async () => 'Unauthorized',
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(false);
expect(result.error).toContain('401');
});
it('should return error when API returns errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [{ message: 'Rate limit exceeded' }],
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded');
});
});
describe('getHomeLatestTimeline', () => {
it('should return tweets from Following feed', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => makeHomeTimelineResponse('789012', 'Hello from Following feed!', 'chronouser'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeLatestTimeline(1);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0].id).toBe('789012');
expect(result.tweets?.[0].text).toBe('Hello from Following feed!');
expect(result.tweets?.[0].author.username).toBe('chronouser');
});
it('should return error on non-ok response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeLatestTimeline(1);
expect(result.success).toBe(false);
expect(result.error).toContain('403');
});
});
describe('pagination', () => {
it('returns the next cursor when stopping before the end', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor123',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(true);
expect(result.nextCursor).toBe('cursor123');
});
it('resumes from a provided cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => makeHomeTimelineResponse('tweet2', 'Second page tweet', 'user2'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeLatestTimeline(1, { cursor: 'resume-cursor' });
expect(result.success).toBe(true);
const callUrl = String(mockFetch.mock.calls[0]?.[0]);
const variables = JSON.parse(new URL(callUrl).searchParams.get('variables') ?? '{}') as { cursor?: string };
expect(variables.cursor).toBe('resume-cursor');
});
it('stops at maxPages and returns the cursor to resume', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(5, { maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.nextCursor).toBe('cursor-page-2');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should deduplicate tweets across pages', async () => {
// First page
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor123',
},
},
],
},
],
},
},
},
}),
});
// Second page with duplicate
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1', // duplicate
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: { full_text: 'Second tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u2', legacy: { screen_name: 'user2', name: 'User 2' } },
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(2);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('tweet1');
expect(result.tweets?.[1].id).toBe('tweet2');
});
it('stops when a page only returns duplicates', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor123',
},
},
],
},
],
},
},
},
}),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor456',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(5);
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});