Files
bird/tests/twitter-client.user-tweets.test.ts
yuta 1f6e228272
CI / test (pull_request) Has been cancelled
Use twitter safe relay
2026-06-24 03:39:34 +09:00

422 lines
13 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();
});
describe('TwitterClient getUserTweets', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const makeTweetResult = (id: string, text: string, username = 'testuser') => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: '12345',
legacy: {
screen_name: username,
name: 'Test User',
},
},
},
},
});
const makeTimelineResponse = (tweets: ReturnType<typeof makeTweetResult>[], cursor?: string) => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
...tweets.map((t) => ({
entryId: `tweet-${t.rest_id}`,
content: {
entryType: 'TimelineTimelineItem',
itemContent: {
tweet_results: {
result: t,
},
},
},
})),
...(cursor
? [
{
entryId: 'cursor-bottom-123',
content: {
entryType: 'TimelineTimelineCursor',
cursorType: 'Bottom',
value: cursor,
},
},
]
: []),
],
},
],
},
},
},
},
},
});
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches and parses user tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([makeTweetResult('1', 'First tweet'), makeTweetResult('2', 'Second tweet')]),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.tweets?.[0].id).toBe('1');
expect(result.tweets?.[0].text).toBe('First tweet');
expect(result.tweets?.[1].id).toBe('2');
expect(result.tweets?.[1].text).toBe('Second tweet');
});
it('respects count parameter', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([
makeTweetResult('1', 'First'),
makeTweetResult('2', 'Second'),
makeTweetResult('3', 'Third'),
]),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 2);
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
});
it('returns empty array for user with no tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([]),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true);
expect(result.tweets).toEqual([]);
});
it('extracts cursor for pagination', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet')], 'next-cursor-abc'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 20, { maxPages: 1 });
expect(result.success).toBe(true);
expect(result.nextCursor).toBe('next-cursor-abc');
});
it('handles API errors gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
});
describe('TwitterClient getUserTweetsPaged', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const makeTweetResult = (id: string, text: string) => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: '12345',
legacy: {
screen_name: 'testuser',
name: 'Test User',
},
},
},
},
});
const makeTimelineResponse = (tweets: ReturnType<typeof makeTweetResult>[], cursor?: string) => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
...tweets.map((t) => ({
entryId: `tweet-${t.rest_id}`,
content: {
entryType: 'TimelineTimelineItem',
itemContent: {
tweet_results: {
result: t,
},
},
},
})),
...(cursor
? [
{
entryId: 'cursor-bottom-123',
content: {
entryType: 'TimelineTimelineCursor',
cursorType: 'Bottom',
value: cursor,
},
},
]
: []),
],
},
],
},
},
},
},
},
});
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('requests only the remaining count', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse(
[makeTweetResult('1', 'Tweet 1'), makeTweetResult('2', 'Tweet 2'), makeTweetResult('3', 'Tweet 3')],
'cursor-2',
),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 5, { pageDelayMs: 0 });
expect(result.success).toBe(true);
const [url] = mockFetch.mock.calls[0];
const variablesRaw = new URL(url).searchParams.get('variables');
expect(variablesRaw).not.toBeNull();
const variables = JSON.parse(String(variablesRaw)) as { count?: number };
expect(variables.count).toBe(5);
});
it('enforces a hard 10 page cap by default', async () => {
for (let page = 1; page <= 10; page += 1) {
const startId = (page - 1) * 20 + 1;
const tweets = Array.from({ length: 20 }, (_v, index) => {
const id = String(startId + index);
return makeTweetResult(id, `Tweet ${id}`);
});
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse(tweets, `cursor-${page + 1}`),
});
}
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 9999, { pageDelayMs: 0 });
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(10);
expect(result.tweets?.length).toBe(200);
expect(result.nextCursor).toBe('cursor-11');
});
it('returns error for invalid limit', async () => {
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 0);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid limit');
expect(mockFetch).not.toHaveBeenCalled();
});
it('fetches multiple pages with cursor', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Page 1 Tweet')], 'cursor-page-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Page 2 Tweet')], 'cursor-page-3'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2,
pageDelayMs: 0, // No delay for tests
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.tweets?.[0].id).toBe('1');
expect(result.tweets?.[1].id).toBe('2');
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('respects maxPages limit', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')], 'cursor-3'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result.nextCursor).toBe('cursor-3'); // Has more but stopped at maxPages
});
it('stops when no more cursor is returned', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')]), // No cursor = end
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 100, {
maxPages: 5,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('deduplicates tweets across pages', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([
makeTweetResult('1', 'Tweet 1'), // Duplicate
makeTweetResult('2', 'Tweet 2'),
]),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2); // Not 3
expect(result.tweets?.map((t) => t.id)).toEqual(['1', '2']);
});
it('resumes from provided cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('5', 'Resumed Tweet')]),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 20, {
maxPages: 1,
cursor: 'resume-cursor-xyz',
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('5');
// Verify cursor was passed in the request
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('resume-cursor-xyz');
});
});