This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { Command } from 'commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CliContext } from '../src/cli/shared.js';
|
||||
import { registerHomeCommand } from '../src/commands/home.js';
|
||||
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||
|
||||
describe('home command', () => {
|
||||
let program: Command;
|
||||
let mockContext: Partial<CliContext>;
|
||||
|
||||
beforeEach(() => {
|
||||
program = new Command();
|
||||
mockContext = {
|
||||
resolveTimeoutFromOptions: () => undefined,
|
||||
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
|
||||
resolveProfileNameFromOptions: () => undefined,
|
||||
p: () => '',
|
||||
printTweetsResult: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('uses cursor pagination when --cursor is set', async () => {
|
||||
registerHomeCommand(program, mockContext as CliContext);
|
||||
const timelineSpy = vi
|
||||
.spyOn(TwitterClient.prototype, 'getHomeLatestTimeline')
|
||||
.mockResolvedValue({ success: true, tweets: [], nextCursor: 'next-1' });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'bird', 'home', '--following', '--cursor', 'cursor-1']);
|
||||
expect(timelineSpy).toHaveBeenCalledWith(20, expect.objectContaining({ cursor: 'cursor-1' }));
|
||||
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ nextCursor: 'next-1' }), {
|
||||
json: false,
|
||||
usePagination: true,
|
||||
emptyMessage: 'No tweets found in Following timeline.',
|
||||
});
|
||||
} finally {
|
||||
timelineSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses paged home timeline when --max-pages is set', async () => {
|
||||
registerHomeCommand(program, mockContext as CliContext);
|
||||
const timelineSpy = vi.spyOn(TwitterClient.prototype, 'getHomeTimeline').mockResolvedValue({
|
||||
success: true,
|
||||
tweets: [],
|
||||
nextCursor: 'next-2',
|
||||
});
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'bird', 'home', '--max-pages', '3', '--json']);
|
||||
expect(timelineSpy).toHaveBeenCalledWith(20, expect.objectContaining({ maxPages: 3 }));
|
||||
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ nextCursor: 'next-2' }), {
|
||||
json: true,
|
||||
usePagination: true,
|
||||
emptyMessage: 'No tweets found in For You timeline.',
|
||||
});
|
||||
} finally {
|
||||
timelineSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,121 @@ describe('TwitterClient home timeline', () => {
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user