diff --git a/CHANGELOG.md b/CHANGELOG.md index 267d35a..df4963b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. +- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern. - Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli. - Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv. diff --git a/src/commands/users.ts b/src/commands/users.ts index 57be392..9c834cf 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -28,8 +28,8 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; const usePagination = cmdOpts.all || cmdOpts.cursor; - if (maxPages !== undefined && !usePagination) { - console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); + if (maxPages !== undefined && !cmdOpts.all) { + console.error(`${ctx.p('err')}--max-pages requires --all.`); process.exit(1); } if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { @@ -37,28 +37,28 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { process.exit(1); } - const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - for (const warning of warnings) { - console.error(`${ctx.p('warn')}${warning}`); - } + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } - if (!cookies.authToken || !cookies.ct0) { - console.error(`${ctx.p('err')}Missing required credentials`); - process.exit(1); - } - - const client = new TwitterClient({ cookies, timeoutMs }); - - let userId = cmdOpts.user; - if (!userId) { - const currentUser = await client.getCurrentUser(); - if (!currentUser.success || !currentUser.user?.id) { - console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); process.exit(1); } - userId = currentUser.user.id; - } + + const client = new TwitterClient({ cookies, timeoutMs }); + + let userId = cmdOpts.user; + if (!userId) { + const currentUser = await client.getCurrentUser(); + if (!currentUser.success || !currentUser.user?.id) { + console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`); + process.exit(1); + } + userId = currentUser.user.id; + } if (cmdOpts.all) { // Fetch all pages @@ -81,35 +81,39 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { process.exit(1); } + let added = 0; for (const user of result.users) { if (!seen.has(user.id)) { seen.add(user.id); allUsers.push(user); + added += 1; } } - if (!result.nextCursor || result.users.length === 0) { + const pageCursor = result.nextCursor; + if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) { nextCursor = undefined; break; } if (maxPages && pageNum >= maxPages) { - nextCursor = result.nextCursor; + nextCursor = pageCursor; break; } - cursor = result.nextCursor; + cursor = pageCursor; // Rate limit: wait between pages to avoid overwhelming the API await new Promise((resolve) => setTimeout(resolve, 1000)); } if (cmdOpts.json) { - console.log(JSON.stringify(allUsers, null, 2)); + console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2)); } else { console.error(`${ctx.p('info')}Total: ${allUsers.length} users`); if (nextCursor) { console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`); + console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`); } for (const user of allUsers) { console.log(`@${user.username} (${user.name})`); @@ -128,7 +132,11 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { if (result.success && result.users) { if (cmdOpts.json) { - console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor }, null, 2)); + if (usePagination) { + console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2)); + } else { + console.log(JSON.stringify(result.users, null, 2)); + } } else { if (result.users.length === 0) { console.log('No users found.'); @@ -180,8 +188,8 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; const usePagination = cmdOpts.all || cmdOpts.cursor; - if (maxPages !== undefined && !usePagination) { - console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); + if (maxPages !== undefined && !cmdOpts.all) { + console.error(`${ctx.p('err')}--max-pages requires --all.`); process.exit(1); } if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { @@ -233,35 +241,39 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { process.exit(1); } + let added = 0; for (const user of result.users) { if (!seen.has(user.id)) { seen.add(user.id); allUsers.push(user); + added += 1; } } - if (!result.nextCursor || result.users.length === 0) { + const pageCursor = result.nextCursor; + if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) { nextCursor = undefined; break; } if (maxPages && pageNum >= maxPages) { - nextCursor = result.nextCursor; + nextCursor = pageCursor; break; } - cursor = result.nextCursor; + cursor = pageCursor; // Rate limit: wait between pages to avoid overwhelming the API await new Promise((resolve) => setTimeout(resolve, 1000)); } if (cmdOpts.json) { - console.log(JSON.stringify(allUsers, null, 2)); + console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2)); } else { console.error(`${ctx.p('info')}Total: ${allUsers.length} users`); if (nextCursor) { console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`); + console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`); } for (const user of allUsers) { console.log(`@${user.username} (${user.name})`); @@ -280,7 +292,11 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { if (result.success && result.users) { if (cmdOpts.json) { - console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor }, null, 2)); + if (usePagination) { + console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2)); + } else { + console.log(JSON.stringify(result.users, null, 2)); + } } else { if (result.users.length === 0) { console.log('No users found.'); diff --git a/tests/commands.users.test.ts b/tests/commands.users.test.ts new file mode 100644 index 0000000..6d36c72 --- /dev/null +++ b/tests/commands.users.test.ts @@ -0,0 +1,113 @@ +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CliContext } from '../src/cli/shared.js'; +import { registerUserCommands } from '../src/commands/users.js'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +const baseCtx = { + resolveTimeoutFromOptions: () => undefined, + resolveCredentialsFromOptions: async () => ({ + cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' }, + warnings: [], + }), + p: () => '', + printTweets: () => undefined, +} as unknown as CliContext; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('users commands', () => { + it('requires --all when --max-pages is provided', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + 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', 'following', '--max-pages', '2'])).rejects.toThrow('exit 1'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all.')); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it('emits pagination JSON when --cursor is provided', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'next-1', + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev', '--json']); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.users).toHaveLength(1); + expect(payload.nextCursor).toBe('next-1'); + }); + + it('emits pagination JSON for --all results', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'next-1', + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'bird', 'following', '--all', '--max-pages', '1', '--json']); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.users).toHaveLength(1); + expect(payload.nextCursor).toBe('next-1'); + }); + + it('stops pagination when cursor repeats or no new users are added', async () => { + vi.useFakeTimers(); + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + const followingSpy = vi.spyOn(TwitterClient.prototype, 'getFollowing'); + followingSpy + .mockResolvedValueOnce({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'cursor-1', + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const run = program.parseAsync(['node', 'bird', 'following', '--all', '--json']); + await vi.advanceTimersByTimeAsync(1000); + await run; + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.users).toHaveLength(1); + expect(payload.nextCursor).toBeNull(); + expect(followingSpy).toHaveBeenCalledTimes(2); + }); +});