refactor: align replies/thread pagination (#35) (thanks @crcatala)
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
|
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
|
||||||
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
|
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
|
||||||
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
|
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
|
||||||
|
- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
|
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ bird 1234567890123456789 --json
|
|||||||
# Thread + replies
|
# Thread + replies
|
||||||
bird thread https://x.com/user/status/1234567890123456789
|
bird thread https://x.com/user/status/1234567890123456789
|
||||||
bird replies 1234567890123456789
|
bird replies 1234567890123456789
|
||||||
|
bird replies 1234567890123456789 --max-pages 3 --json
|
||||||
|
bird thread 1234567890123456789 --max-pages 3 --json
|
||||||
|
|
||||||
# Search + mentions
|
# Search + mentions
|
||||||
bird search "from:steipete" -n 5
|
bird search "from:steipete" -n 5
|
||||||
@@ -100,8 +102,8 @@ const result = await client.search('from:steipete', 50);
|
|||||||
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
|
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
|
||||||
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
|
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
|
||||||
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
|
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
|
||||||
- `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet.
|
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet.
|
||||||
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
|
- `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread.
|
||||||
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
||||||
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
||||||
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline.
|
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline.
|
||||||
@@ -177,7 +179,7 @@ Environment shortcuts:
|
|||||||
## Output
|
## Output
|
||||||
|
|
||||||
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes.
|
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes.
|
||||||
- When using `--json` with pagination (`--cursor`, `--max-pages`, or `-n > 20`), `user-tweets` prints `{ tweets, nextCursor }`.
|
- When using `--json` with pagination (`--all`, `--cursor`, `--max-pages`, or for `user-tweets` when `-n > 20`), output is `{ tweets, nextCursor }`.
|
||||||
- `read` returns full text for Notes and Articles when present.
|
- `read` returns full text for Notes and Articles when present.
|
||||||
- Use `--plain` for stable, script-friendly output (no emoji, no color).
|
- Use `--plain` for stable, script-friendly output (no emoji, no color).
|
||||||
|
|
||||||
|
|||||||
+24
-33
@@ -50,8 +50,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
.description('List replies to a tweet (by ID or URL)')
|
.description('List replies to a tweet (by ID or URL)')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
.option('--all', 'Fetch all replies (paged)')
|
.option('--all', 'Fetch all replies (paged)')
|
||||||
.option('--max-pages <number>', 'Stop after N pages when using --all')
|
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
|
||||||
.option('--delay <ms>', 'Delay in ms between page fetches (default: 1000)')
|
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
|
||||||
.option('--cursor <string>', 'Resume pagination from a cursor')
|
.option('--cursor <string>', 'Resume pagination from a cursor')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
@@ -72,13 +72,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
||||||
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
|
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
|
||||||
const pageDelayMs = cmdOpts.delay ? Number.parseInt(cmdOpts.delay, 10) : 1000;
|
const pageDelayMs = Number.parseInt(cmdOpts.delay || '1000', 10);
|
||||||
|
|
||||||
const usePagination = cmdOpts.all || cmdOpts.cursor;
|
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || maxPages !== undefined);
|
||||||
if (maxPages !== undefined && !usePagination) {
|
|
||||||
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
|
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
|
||||||
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
|
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -111,18 +107,19 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
})
|
})
|
||||||
: await client.getReplies(tweetId, { includeRaw });
|
: await client.getReplies(tweetId, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.tweets) {
|
||||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
|
||||||
if (isJson && usePagination) {
|
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage: 'No replies found.' });
|
||||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
|
||||||
} else {
|
|
||||||
ctx.printTweets(result.tweets, { json: isJson, emptyMessage: 'No replies found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show pagination hint if there's more
|
// Show pagination hint if there's more
|
||||||
if (result.nextCursor && !isJson) {
|
if (result.nextCursor && !isJson) {
|
||||||
console.error(`${ctx.p('info')}More replies available. Use --cursor "${result.nextCursor}" to continue.`);
|
console.error(`${ctx.p('info')}More replies available. Use --cursor "${result.nextCursor}" to continue.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -135,8 +132,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
.description('Show the full conversation thread containing the tweet')
|
.description('Show the full conversation thread containing the tweet')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
.option('--all', 'Fetch all thread tweets (paged)')
|
.option('--all', 'Fetch all thread tweets (paged)')
|
||||||
.option('--max-pages <number>', 'Stop after N pages when using --all')
|
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
|
||||||
.option('--delay <ms>', 'Delay in ms between page fetches (default: 1000)')
|
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
|
||||||
.option('--cursor <string>', 'Resume pagination from a cursor')
|
.option('--cursor <string>', 'Resume pagination from a cursor')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
@@ -157,13 +154,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
|
||||||
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
|
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
|
||||||
const pageDelayMs = cmdOpts.delay ? Number.parseInt(cmdOpts.delay, 10) : 1000;
|
const pageDelayMs = Number.parseInt(cmdOpts.delay || '1000', 10);
|
||||||
|
|
||||||
const usePagination = cmdOpts.all || cmdOpts.cursor;
|
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || maxPages !== undefined);
|
||||||
if (maxPages !== undefined && !usePagination) {
|
|
||||||
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
|
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
|
||||||
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
|
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -196,16 +189,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
})
|
})
|
||||||
: await client.getThread(tweetId, { includeRaw });
|
: await client.getThread(tweetId, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.tweets) {
|
||||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
|
||||||
if (isJson && usePagination) {
|
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage: 'No thread tweets found.' });
|
||||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
|
||||||
} else {
|
|
||||||
ctx.printTweets(result.tweets, {
|
|
||||||
json: isJson,
|
|
||||||
emptyMessage: 'No thread tweets found.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show pagination hint if there's more
|
// Show pagination hint if there's more
|
||||||
if (result.nextCursor && !isJson) {
|
if (result.nextCursor && !isJson) {
|
||||||
@@ -213,6 +199,11 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
`${ctx.p('info')}More thread tweets available. Use --cursor "${result.nextCursor}" to continue.`,
|
`${ctx.p('info')}More thread tweets available. Use --cursor "${result.nextCursor}" to continue.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
// If we have some replies already, return them with the error
|
// If we have some replies already, return them with the error
|
||||||
if (allReplies.length > 0) {
|
if (allReplies.length > 0) {
|
||||||
return { success: true, tweets: allReplies, nextCursor: cursor, error: response.error };
|
return { success: false, tweets: allReplies, nextCursor: cursor, error: response.error };
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -402,7 +402,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageCursor = extractCursorFromInstructions(instructions);
|
const pageCursor = extractCursorFromInstructions(instructions);
|
||||||
if (!pageCursor || pageCursor === cursor || replies.length === 0) {
|
if (!pageCursor || pageCursor === cursor) {
|
||||||
nextCursor = undefined;
|
nextCursor = undefined;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -441,7 +441,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
// If we have some tweets already, return them with the error
|
// If we have some tweets already, return them with the error
|
||||||
if (allTweets.length > 0) {
|
if (allTweets.length > 0) {
|
||||||
return { success: true, tweets: allTweets, nextCursor: cursor, error: response.error };
|
return { success: false, tweets: allTweets, nextCursor: cursor, error: response.error };
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageCursor = extractCursorFromInstructions(instructions);
|
const pageCursor = extractCursorFromInstructions(instructions);
|
||||||
if (!pageCursor || pageCursor === cursor || threadTweets.length === 0) {
|
if (!pageCursor || pageCursor === cursor) {
|
||||||
nextCursor = undefined;
|
nextCursor = undefined;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-20
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { CliContext } from '../src/cli/shared.js';
|
import type { CliContext } from '../src/cli/shared.js';
|
||||||
import { registerReadCommands } from '../src/commands/read.js';
|
import { registerReadCommands } from '../src/commands/read.js';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
|
||||||
describe('replies command', () => {
|
describe('replies command', () => {
|
||||||
const createMockContext = () =>
|
const createMockContext = () =>
|
||||||
@@ -15,25 +16,25 @@ describe('replies command', () => {
|
|||||||
}),
|
}),
|
||||||
p: () => '',
|
p: () => '',
|
||||||
printTweets: () => undefined,
|
printTweets: () => undefined,
|
||||||
|
printTweetsResult: () => undefined,
|
||||||
}) as unknown as CliContext;
|
}) as unknown as CliContext;
|
||||||
|
|
||||||
it('requires --all or --cursor when --max-pages is provided', async () => {
|
it('uses pagination when --max-pages is provided', async () => {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
registerReadCommands(program, createMockContext());
|
registerReadCommands(program, createMockContext());
|
||||||
|
|
||||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
const pagedSpy = vi
|
||||||
throw new Error(`exit ${code}`);
|
.spyOn(TwitterClient.prototype, 'getRepliesPaged')
|
||||||
}) as never);
|
.mockResolvedValue({ success: true, tweets: [], nextCursor: undefined });
|
||||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getReplies').mockResolvedValue({ success: true, tweets: [] });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(program.parseAsync(['node', 'bird', 'replies', '123', '--max-pages', '2'])).rejects.toThrow(
|
await program.parseAsync(['node', 'bird', 'replies', '123', '--max-pages', '2', '--json']);
|
||||||
'exit 1',
|
expect(pagedSpy).toHaveBeenCalledTimes(1);
|
||||||
);
|
expect(unpagedSpy).toHaveBeenCalledTimes(0);
|
||||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
|
|
||||||
} finally {
|
} finally {
|
||||||
exitSpy.mockRestore();
|
pagedSpy.mockRestore();
|
||||||
errorSpy.mockRestore();
|
unpagedSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,23 +91,25 @@ describe('thread command', () => {
|
|||||||
}),
|
}),
|
||||||
p: () => '',
|
p: () => '',
|
||||||
printTweets: () => undefined,
|
printTweets: () => undefined,
|
||||||
|
printTweetsResult: () => undefined,
|
||||||
}) as unknown as CliContext;
|
}) as unknown as CliContext;
|
||||||
|
|
||||||
it('requires --all or --cursor when --max-pages is provided', async () => {
|
it('uses pagination when --max-pages is provided', async () => {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
registerReadCommands(program, createMockContext());
|
registerReadCommands(program, createMockContext());
|
||||||
|
|
||||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
const pagedSpy = vi
|
||||||
throw new Error(`exit ${code}`);
|
.spyOn(TwitterClient.prototype, 'getThreadPaged')
|
||||||
}) as never);
|
.mockResolvedValue({ success: true, tweets: [], nextCursor: undefined });
|
||||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getThread').mockResolvedValue({ success: true, tweets: [] });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--max-pages', '2'])).rejects.toThrow('exit 1');
|
await program.parseAsync(['node', 'bird', 'thread', '123', '--max-pages', '2', '--json']);
|
||||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
|
expect(pagedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(unpagedSpy).toHaveBeenCalledTimes(0);
|
||||||
} finally {
|
} finally {
|
||||||
exitSpy.mockRestore();
|
pagedSpy.mockRestore();
|
||||||
errorSpy.mockRestore();
|
unpagedSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -185,11 +185,38 @@ describe('TwitterClient pagination for thread and replies', () => {
|
|||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
|
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(false);
|
||||||
expect(result.tweets?.length).toBe(2);
|
expect(result.tweets?.length).toBe(2);
|
||||||
expect(result.error).toContain('500');
|
expect(result.error).toContain('500');
|
||||||
expect(result.nextCursor).toBe('cursor-page-2');
|
expect(result.nextCursor).toBe('cursor-page-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continues pagination even when a page contains no replies', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => makeConversationPayload(['2'], 'cursor-page-2', '1'),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => makeConversationPayload(['10'], 'cursor-page-3', undefined),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => makeConversationPayload(['3'], undefined, '1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweets?.map((t) => t.id)).toEqual(['2', '3']);
|
||||||
|
expect(result.nextCursor).toBeUndefined();
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getThreadPaged', () => {
|
describe('getThreadPaged', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user