feat: add pagination support for thread and replies commands
Add --all, --max-pages, --delay, and --cursor options to both thread and replies commands, enabling fetching of all conversation content with rate-limit-friendly pagination. Changes: - Modified fetchTweetDetail() to accept optional cursor parameter - Added getRepliesPaged() and getThreadPaged() methods with pagination loop - Updated CLI commands with new options (--all, --max-pages, --delay, --cursor) - Added TweetDetailPaginationOptions interface - JSON output includes nextCursor for scripting/resumption Options: - --all: Fetch all pages (no hard limit) - --max-pages <n>: Limit number of pages when using --all - --delay <ms>: Delay between page fetches (default: 1000ms) - --cursor <string>: Resume from a previous cursor Follows patterns established by bookmarks command and PR #34 (user-tweets). Co-authored-by: Christian Catalan <[email protected]>
This commit is contained in:
committed by
Peter Steinberger
co-authored by
Christian Catalan
parent
c7e2ec0495
commit
90037ce880
@@ -0,0 +1,152 @@
|
||||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CliContext } from '../src/cli/shared.js';
|
||||
import { registerReadCommands } from '../src/commands/read.js';
|
||||
|
||||
describe('replies command', () => {
|
||||
const createMockContext = () =>
|
||||
({
|
||||
resolveTimeoutFromOptions: () => undefined,
|
||||
resolveQuoteDepthFromOptions: () => 1,
|
||||
extractTweetId: (input: string) => input,
|
||||
resolveCredentialsFromOptions: async () => ({
|
||||
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
|
||||
warnings: [],
|
||||
}),
|
||||
p: () => '',
|
||||
printTweets: () => undefined,
|
||||
}) as unknown as CliContext;
|
||||
|
||||
it('requires --all or --cursor when --max-pages is provided', async () => {
|
||||
const program = new Command();
|
||||
registerReadCommands(program, createMockContext());
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['node', 'bird', 'replies', '123', '--max-pages', '2'])).rejects.toThrow(
|
||||
'exit 1',
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates --max-pages is a positive integer', async () => {
|
||||
const program = new Command();
|
||||
registerReadCommands(program, createMockContext());
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'bird', 'replies', '123', '--all', '--max-pages', '-1']),
|
||||
).rejects.toThrow('exit 1');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --max-pages'));
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates --delay is a non-negative integer', async () => {
|
||||
const program = new Command();
|
||||
registerReadCommands(program, createMockContext());
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['node', 'bird', 'replies', '123', '--all', '--delay', '-100'])).rejects.toThrow(
|
||||
'exit 1',
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --delay'));
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('thread command', () => {
|
||||
const createMockContext = () =>
|
||||
({
|
||||
resolveTimeoutFromOptions: () => undefined,
|
||||
resolveQuoteDepthFromOptions: () => 1,
|
||||
extractTweetId: (input: string) => input,
|
||||
resolveCredentialsFromOptions: async () => ({
|
||||
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
|
||||
warnings: [],
|
||||
}),
|
||||
p: () => '',
|
||||
printTweets: () => undefined,
|
||||
}) as unknown as CliContext;
|
||||
|
||||
it('requires --all or --cursor when --max-pages is provided', async () => {
|
||||
const program = new Command();
|
||||
registerReadCommands(program, createMockContext());
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--max-pages', '2'])).rejects.toThrow('exit 1');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates --max-pages is a positive integer', async () => {
|
||||
const program = new Command();
|
||||
registerReadCommands(program, createMockContext());
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--all', '--max-pages', '0'])).rejects.toThrow(
|
||||
'exit 1',
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --max-pages'));
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates --delay is a non-negative integer', async () => {
|
||||
const program = new Command();
|
||||
registerReadCommands(program, createMockContext());
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as never);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--all', '--delay', 'abc'])).rejects.toThrow(
|
||||
'exit 1',
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --delay'));
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user