diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index 6ff9d0bc..0a5434c1 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -205,6 +205,8 @@ See `src/commands/rooms/messages/update.ts` and `src/commands/rooms/messages/del ## History Pattern ```typescript +import { collectPaginatedResults, formatPaginationWarning } from "../../utils/pagination.js"; + async run(): Promise { const { args, flags } = await this.parse(MyHistoryCommand); @@ -222,14 +224,24 @@ async run(): Promise { }; const history = await channel.history(historyParams); - const messages = history.items; + const { items: messages, hasMore, pagesConsumed } = await collectPaginatedResults(history, flags.limit); + + const paginationWarning = formatPaginationWarning(pagesConsumed, messages.length); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } if (this.shouldOutputJson(flags)) { // Plural domain key for collections, optional metadata alongside - this.logJsonResult({ messages, total: messages.length }, flags); + this.logJsonResult({ messages, hasMore, total: messages.length }, flags); } else { this.log(formatSuccess(`Found ${messages.length} messages.`)); // Display each message using multi-line labeled blocks + + if (hasMore) { + const warning = formatLimitWarning(messages.length, flags.limit, "messages"); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "history", { channel: args.channel }); @@ -407,11 +419,55 @@ async run(): Promise { } ``` +**Product API list with pagination** (e.g., `push devices list`, `channels list`) — use `collectPaginatedResults`: +```typescript +import { buildPaginationNext, collectPaginatedResults, formatPaginationWarning } from "../../utils/pagination.js"; + +async run(): Promise { + const { flags } = await this.parse(MyListCommand); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const firstPage = await rest.someResource.list({ limit: flags.limit }); + const { items, hasMore, pagesConsumed } = await collectPaginatedResults(firstPage, flags.limit); + + const paginationWarning = formatPaginationWarning(pagesConsumed, items.length); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } + + if (this.shouldOutputJson(flags)) { + const next = buildPaginationNext(hasMore); + this.logJsonResult({ items, hasMore, ...(next && { next }) }, flags); + } else { + this.log(`Found ${items.length} items:\n`); + for (const item of items) { + this.log(formatHeading(`Item ID: ${item.id}`)); + this.log(` ${formatLabel("Type")} ${item.type}`); + this.log(""); + } + + if (hasMore) { + const warning = formatLimitWarning(items.length, flags.limit, "items"); + if (warning) this.log(warning); + } + } + } catch (error) { + this.fail(error, flags, "listItems"); + } +} +``` + Key conventions for list output: - `formatResource()` is for inline resource name references, not for record headings - `formatHeading()` is for record heading lines that act as visual separators between multi-field records - `formatLabel(text)` for field labels in detail lines (automatically appends `:`) - `formatSuccess()` is not used in list commands — it's for confirming an action completed +- `formatLimitWarning()` should only be shown when `hasMore` is true — it means there are more results beyond the limit +- Always include `hasMore` and `next` in JSON output for paginated commands. `next` provides continuation hints (and `start` timestamp for history commands) +- Use `collectPaginatedResults()` for SDK paginated results and `collectFilteredPaginatedResults()` when a client-side filter is applied across pages --- diff --git a/README.md b/README.md index e35b9df5..4a010fb0 100644 --- a/README.md +++ b/README.md @@ -543,12 +543,13 @@ List all apps in the current account ``` USAGE - $ ably apps list [-v] [--json | --pretty-json] + $ ably apps list [-v] [--json | --pretty-json] [--limit ] FLAGS - -v, --verbose Output verbose logs - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return (default: 100) + --pretty-json Output in colorized JSON format DESCRIPTION List all apps in the current account @@ -676,13 +677,14 @@ List channel rules for an app ``` USAGE - $ ably apps rules list [-v] [--json | --pretty-json] [--app ] + $ ably apps rules list [-v] [--json | --pretty-json] [--app ] [--limit ] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return (default: 100) + --pretty-json Output in colorized JSON format DESCRIPTION List channel rules for an app @@ -1063,13 +1065,14 @@ List all keys in the app ``` USAGE - $ ably auth keys list [-v] [--json | --pretty-json] [--app ] + $ ably auth keys list [-v] [--json | --pretty-json] [--app ] [--limit ] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return (default: 100) + --pretty-json Output in colorized JSON format DESCRIPTION List all keys in the app @@ -2407,13 +2410,14 @@ List all integrations ``` USAGE - $ ably integrations list [-v] [--json | --pretty-json] [--app ] + $ ably integrations list [-v] [--json | --pretty-json] [--app ] [--limit ] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return (default: 100) + --pretty-json Output in colorized JSON format DESCRIPTION List all integrations @@ -3570,13 +3574,14 @@ List all queues ``` USAGE - $ ably queues list [-v] [--json | --pretty-json] [--app ] + $ ably queues list [-v] [--json | --pretty-json] [--app ] [--limit ] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return (default: 100) + --pretty-json Output in colorized JSON format DESCRIPTION List all queues diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index 6b2fb6db..0682224d 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -88,6 +88,7 @@ This document outlines the directory structure of the Ably CLI project. │ ├── history.ts # History query parameter builder │ ├── message.ts # Message interpolation ({{.Count}}, {{.Timestamp}}) │ ├── output.ts # Output helpers (progress, success, resource, etc.) +│ ├── pagination.ts # Generic pagination utilities (collectPaginatedResults, collectFilteredPaginatedResults) │ ├── prompt-confirmation.ts # Y/N confirmation prompts │ ├── readline-helper.ts # Readline utilities for interactive mode │ ├── sigint-exit.ts # SIGINT/Ctrl+C handling (exit code 130) diff --git a/src/commands/apps/list.ts b/src/commands/apps/list.ts index 1594c49b..b15ae4a4 100644 --- a/src/commands/apps/list.ts +++ b/src/commands/apps/list.ts @@ -1,6 +1,8 @@ +import { Flags } from "@oclif/core"; import chalk from "chalk"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { formatLimitWarning } from "../../utils/output.js"; export default class AppsList extends ControlBaseCommand { static override description = "List all apps in the current account"; @@ -13,6 +15,10 @@ export default class AppsList extends ControlBaseCommand { static override flags = { ...ControlBaseCommand.globalFlags, + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return (default: 100)", + }), }; async run(): Promise { @@ -21,7 +27,9 @@ export default class AppsList extends ControlBaseCommand { await this.runControlCommand( flags, async (controlApi) => { - const apps = await controlApi.listApps(); + const allApps = await controlApi.listApps(); + const hasMore = allApps.length > flags.limit; + const apps = allApps.slice(0, flags.limit); // Get current app ID from config const currentAppId = this.configManager.getCurrentAppId(); @@ -33,7 +41,7 @@ export default class AppsList extends ControlBaseCommand { isCurrent: app.id === currentAppId, })); - this.logJsonResult({ apps: appsWithCurrentFlag }, flags); + this.logJsonResult({ apps: appsWithCurrentFlag, hasMore }, flags); return; } @@ -77,6 +85,11 @@ export default class AppsList extends ControlBaseCommand { this.log(""); // Add a blank line between apps } + + if (hasMore) { + const warning = formatLimitWarning(apps.length, flags.limit, "apps"); + if (warning) this.log(warning); + } }, "Error listing apps", ); diff --git a/src/commands/apps/rules/list.ts b/src/commands/apps/rules/list.ts index 4c0b798e..47d34ac5 100644 --- a/src/commands/apps/rules/list.ts +++ b/src/commands/apps/rules/list.ts @@ -3,7 +3,11 @@ import type { Namespace } from "../../../services/control-api.js"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { formatCountLabel, formatHeading } from "../../../utils/output.js"; +import { + formatCountLabel, + formatHeading, + formatLimitWarning, +} from "../../../utils/output.js"; interface ChannelRuleOutput { authenticated: boolean; @@ -40,6 +44,10 @@ export default class RulesListCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", required: false, }), + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return (default: 100)", + }), }; async run(): Promise { @@ -48,12 +56,15 @@ export default class RulesListCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - const namespaces = await controlApi.listNamespaces(appId); + const allNamespaces = await controlApi.listNamespaces(appId); + const hasMore = allNamespaces.length > flags.limit; + const namespaces = allNamespaces.slice(0, flags.limit); if (this.shouldOutputJson(flags)) { this.logJsonResult( { appId, + hasMore, rules: namespaces.map( (rule: Namespace): ChannelRuleOutput => ({ authenticated: rule.authenticated || false, @@ -102,6 +113,15 @@ export default class RulesListCommand extends ControlBaseCommand { this.log(""); // Add a blank line between rules }); + + if (hasMore) { + const warning = formatLimitWarning( + namespaces.length, + flags.limit, + "channel rules", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "ruleList", { appId }); diff --git a/src/commands/auth/keys/list.ts b/src/commands/auth/keys/list.ts index 32cf781f..26570dc2 100644 --- a/src/commands/auth/keys/list.ts +++ b/src/commands/auth/keys/list.ts @@ -6,6 +6,7 @@ import { formatCapabilities } from "../../../utils/key-display.js"; import { formatHeading, formatLabel, + formatLimitWarning, formatResource, } from "../../../utils/output.js"; @@ -25,6 +26,10 @@ export default class KeysListCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", env: "ABLY_APP_ID", }), + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return (default: 100)", + }), }; async run(): Promise { @@ -39,7 +44,9 @@ export default class KeysListCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - const keys = await controlApi.listKeys(appId); + const allKeys = await controlApi.listKeys(appId); + const hasMore = allKeys.length > flags.limit; + const keys = allKeys.slice(0, flags.limit); // Get the current key name for highlighting (app_id.key_Id) const currentKeyId = this.configManager.getKeyId(appId); @@ -63,6 +70,7 @@ export default class KeysListCommand extends ControlBaseCommand { this.logJsonResult( { appId, + hasMore, keys: keysWithCurrent, }, flags, @@ -100,6 +108,11 @@ export default class KeysListCommand extends ControlBaseCommand { this.log(""); } + + if (hasMore) { + const warning = formatLimitWarning(keys.length, flags.limit, "keys"); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "keyList", { appId }); diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index d428eac7..064dedc1 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -14,6 +14,11 @@ import { formatMessagesOutput, } from "../../utils/output.js"; import type { MessageDisplayFields } from "../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../utils/pagination.js"; export default class ChannelsHistory extends AblyBaseCommand { static override args = { @@ -83,11 +88,26 @@ export default class ChannelsHistory extends AblyBaseCommand { // Get history const history = await channel.history(historyParams); - const messages = history.items; + const { + items: messages, + hasMore, + pagesConsumed, + } = await collectPaginatedResults(history, flags.limit); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + messages.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } // Display results based on format if (this.shouldOutputJson(flags)) { - this.logJsonResult({ messages }, flags); + const lastTimestamp = + messages.length > 0 ? messages.at(-1)!.timestamp : undefined; + const next = buildPaginationNext(hasMore, lastTimestamp); + this.logJsonResult({ messages, hasMore, ...(next && { next }) }, flags); } else { if (messages.length === 0) { this.log("No messages found in the channel history."); @@ -123,12 +143,14 @@ export default class ChannelsHistory extends AblyBaseCommand { this.log(formatMessagesOutput(displayMessages)); - const warning = formatLimitWarning( - messages.length, - flags.limit, - "messages", - ); - if (warning) this.log(warning); + if (hasMore) { + const warning = formatLimitWarning( + messages.length, + flags.limit, + "messages", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "channelHistory", { diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 2b90cadc..1d1bf139 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -7,6 +7,11 @@ import { formatLimitWarning, formatResource, } from "../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../utils/pagination.js"; interface ChannelMetrics { connections?: number; @@ -93,17 +98,34 @@ export default class ChannelsList extends AblyBaseCommand { ); } - const channels = channelsResponse.items || []; + const { + items: channels, + hasMore, + pagesConsumed, + } = await collectPaginatedResults( + channelsResponse, + flags.limit, + ); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + channels.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } // Output channels based on format if (this.shouldOutputJson(flags)) { + const next = buildPaginationNext(hasMore); this.logJsonResult( { channels: channels.map((channel: ChannelItem) => ({ channelId: channel.channelId, metrics: channel.status?.occupancy?.metrics || {}, })), - hasMore: channels.length === flags.limit, + hasMore, + ...(next && { next }), timestamp: new Date().toISOString(), total: channels.length, }, @@ -119,7 +141,7 @@ export default class ChannelsList extends AblyBaseCommand { `Found ${formatCountLabel(channels.length, "active channel")}:`, ); - for (const channel of channels as ChannelItem[]) { + for (const channel of channels) { this.log(`${formatResource(channel.channelId)}`); // Show occupancy if available @@ -151,12 +173,14 @@ export default class ChannelsList extends AblyBaseCommand { this.log(""); // Add a line break between channels } - const warning = formatLimitWarning( - channels.length, - flags.limit, - "channels", - ); - if (warning) this.log(warning); + if (hasMore) { + const warning = formatLimitWarning( + channels.length, + flags.limit, + "channels", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "channelList"); diff --git a/src/commands/integrations/list.ts b/src/commands/integrations/list.ts index 56efe022..bba2e1ed 100644 --- a/src/commands/integrations/list.ts +++ b/src/commands/integrations/list.ts @@ -1,6 +1,6 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { formatHeading } from "../../utils/output.js"; +import { formatHeading, formatLimitWarning } from "../../utils/output.js"; export default class IntegrationsListCommand extends ControlBaseCommand { static description = "List all integrations"; @@ -18,6 +18,10 @@ export default class IntegrationsListCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", required: false, }), + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return (default: 100)", + }), }; async run(): Promise { @@ -30,12 +34,15 @@ export default class IntegrationsListCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - const integrations = await controlApi.listRules(appId); + const allIntegrations = await controlApi.listRules(appId); + const hasMore = allIntegrations.length > flags.limit; + const integrations = allIntegrations.slice(0, flags.limit); if (this.shouldOutputJson(flags)) { this.logJsonResult( { appId, + hasMore, integrations: integrations.map((integration) => ({ appId: integration.appId, created: new Date(integration.created).toISOString(), @@ -80,6 +87,15 @@ export default class IntegrationsListCommand extends ControlBaseCommand { this.log(` Updated: ${this.formatDate(integration.modified)}`); this.log(""); // Add a blank line between integrations } + + if (hasMore) { + const warning = formatLimitWarning( + integrations.length, + flags.limit, + "integrations", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "integrationList"); diff --git a/src/commands/logs/connection-lifecycle/history.ts b/src/commands/logs/connection-lifecycle/history.ts index da446257..092cfaaa 100644 --- a/src/commands/logs/connection-lifecycle/history.ts +++ b/src/commands/logs/connection-lifecycle/history.ts @@ -12,6 +12,11 @@ import { formatMessageTimestamp, formatLimitWarning, } from "../../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../../utils/pagination.js"; export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { static override description = "Retrieve connection lifecycle log history"; @@ -58,12 +63,28 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { // Get history const history = await channel.history(historyParams); - const messages = history.items; + const { + items: messages, + hasMore, + pagesConsumed, + } = await collectPaginatedResults(history, flags.limit); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + messages.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } // Output results based on format if (this.shouldOutputJson(flags)) { + const lastTimestamp = + messages.length > 0 ? messages.at(-1)!.timestamp : undefined; + const next = buildPaginationNext(hasMore, lastTimestamp); this.logJsonResult( { + hasMore, messages: messages.map((msg) => ({ clientId: msg.clientId, connectionId: msg.connectionId, @@ -73,6 +94,7 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { name: msg.name, timestamp: formatMessageTimestamp(msg.timestamp), })), + ...(next && { next }), }, flags, ); @@ -130,12 +152,14 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { this.log(""); } - const warning = formatLimitWarning( - messages.length, - flags.limit, - "logs", - ); - if (warning) this.log(warning); + if (hasMore) { + const warning = formatLimitWarning( + messages.length, + flags.limit, + "logs", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "connectionLifecycleHistory"); diff --git a/src/commands/logs/history.ts b/src/commands/logs/history.ts index b32a5b6a..ac7ed603 100644 --- a/src/commands/logs/history.ts +++ b/src/commands/logs/history.ts @@ -12,6 +12,11 @@ import { formatMessageTimestamp, formatLimitWarning, } from "../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../utils/pagination.js"; export default class LogsHistory extends AblyBaseCommand { static override description = "Retrieve application log history"; @@ -58,12 +63,28 @@ export default class LogsHistory extends AblyBaseCommand { // Get history const history = await channel.history(historyParams); - const messages = history.items; + const { + items: messages, + hasMore, + pagesConsumed, + } = await collectPaginatedResults(history, flags.limit); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + messages.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } // Output results based on format if (this.shouldOutputJson(flags)) { + const lastTimestamp = + messages.length > 0 ? messages.at(-1)!.timestamp : undefined; + const next = buildPaginationNext(hasMore, lastTimestamp); this.logJsonResult( { + hasMore, messages: messages.map((msg) => ({ clientId: msg.clientId, connectionId: msg.connectionId, @@ -73,6 +94,7 @@ export default class LogsHistory extends AblyBaseCommand { name: msg.name, timestamp: formatMessageTimestamp(msg.timestamp), })), + ...(next && { next }), }, flags, ); @@ -109,12 +131,14 @@ export default class LogsHistory extends AblyBaseCommand { this.log(""); } - const warning = formatLimitWarning( - messages.length, - flags.limit, - "logs", - ); - if (warning) this.log(warning); + if (hasMore) { + const warning = formatLimitWarning( + messages.length, + flags.limit, + "logs", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "logHistory"); diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index 359948a9..ce0d2535 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -14,6 +14,11 @@ import { formatTimestamp, formatLabel, } from "../../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../../utils/pagination.js"; export default class LogsPushHistory extends AblyBaseCommand { static override description = "Retrieve push notification log history"; @@ -59,12 +64,28 @@ export default class LogsPushHistory extends AblyBaseCommand { const historyOptions = buildHistoryParams(flags); const historyPage = await channel.history(historyOptions); - const messages = historyPage.items; + const { + items: messages, + hasMore, + pagesConsumed, + } = await collectPaginatedResults(historyPage, flags.limit); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + messages.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } // Output results based on format if (this.shouldOutputJson(flags)) { + const lastTimestamp = + messages.length > 0 ? messages.at(-1)!.timestamp : undefined; + const next = buildPaginationNext(hasMore, lastTimestamp); this.logJsonResult( { + hasMore, messages: messages.map((msg) => ({ channel: channelName, clientId: msg.clientId, @@ -75,6 +96,7 @@ export default class LogsPushHistory extends AblyBaseCommand { name: msg.name, timestamp: formatMessageTimestamp(msg.timestamp), })), + ...(next && { next }), }, flags, ); @@ -145,12 +167,14 @@ export default class LogsPushHistory extends AblyBaseCommand { this.log(""); } - const warning = formatLimitWarning( - messages.length, - flags.limit, - "logs", - ); - if (warning) this.log(warning); + if (hasMore) { + const warning = formatLimitWarning( + messages.length, + flags.limit, + "logs", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "pushHistory"); diff --git a/src/commands/push/channels/list-channels.ts b/src/commands/push/channels/list-channels.ts index dec9c307..4819208b 100644 --- a/src/commands/push/channels/list-channels.ts +++ b/src/commands/push/channels/list-channels.ts @@ -10,6 +10,11 @@ import { formatResource, formatSuccess, } from "../../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../../utils/pagination.js"; export default class PushChannelsListChannels extends AblyBaseCommand { static override description = "List channels with push subscriptions"; @@ -42,10 +47,23 @@ export default class PushChannelsListChannels extends AblyBaseCommand { const result = await rest.push.admin.channelSubscriptions.listChannels({ limit: flags.limit, }); - const channels = result.items; + const { + items: channels, + hasMore, + pagesConsumed, + } = await collectPaginatedResults(result, flags.limit); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + channels.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } if (this.shouldOutputJson(flags)) { - this.logJsonResult({ channels }, flags); + const next = buildPaginationNext(hasMore); + this.logJsonResult({ channels, hasMore, ...(next && { next }) }, flags); return; } @@ -64,12 +82,14 @@ export default class PushChannelsListChannels extends AblyBaseCommand { } this.log(""); - const limitWarning = formatLimitWarning( - channels.length, - flags.limit, - "channels", - ); - if (limitWarning) this.log(limitWarning); + if (hasMore) { + const limitWarning = formatLimitWarning( + channels.length, + flags.limit, + "channels", + ); + if (limitWarning) this.log(limitWarning); + } } catch (error) { this.fail(error, flags as BaseFlags, "pushChannelListChannels"); } diff --git a/src/commands/push/channels/list.ts b/src/commands/push/channels/list.ts index fe14f9db..389b24ed 100644 --- a/src/commands/push/channels/list.ts +++ b/src/commands/push/channels/list.ts @@ -12,7 +12,13 @@ import { formatProgress, formatResource, formatSuccess, + formatWarning, } from "../../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../../utils/pagination.js"; export default class PushChannelsList extends AblyBaseCommand { static override description = "List push channel subscriptions"; @@ -64,15 +70,31 @@ export default class PushChannelsList extends AblyBaseCommand { if (flags["client-id"]) params.clientId = flags["client-id"]; const result = await rest.push.admin.channelSubscriptions.list(params); - const subscriptions = result.items; + const { + items: subscriptions, + hasMore, + pagesConsumed, + } = await collectPaginatedResults(result, flags.limit); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + subscriptions.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } if (this.shouldOutputJson(flags)) { - this.logJsonResult({ subscriptions }, flags); + const next = buildPaginationNext(hasMore); + this.logJsonResult( + { subscriptions, hasMore, ...(next && { next }) }, + flags, + ); return; } if (subscriptions.length === 0) { - this.log("No subscriptions found."); + this.logToStderr(formatWarning("No subscriptions found.")); return; } @@ -97,12 +119,14 @@ export default class PushChannelsList extends AblyBaseCommand { this.log(""); } - const limitWarning = formatLimitWarning( - subscriptions.length, - flags.limit, - "subscriptions", - ); - if (limitWarning) this.log(limitWarning); + if (hasMore) { + const limitWarning = formatLimitWarning( + subscriptions.length, + flags.limit, + "subscriptions", + ); + if (limitWarning) this.log(limitWarning); + } } catch (error) { this.fail(error, flags as BaseFlags, "pushChannelList"); } diff --git a/src/commands/push/devices/list.ts b/src/commands/push/devices/list.ts index 44ec3cf6..a8fa0a17 100644 --- a/src/commands/push/devices/list.ts +++ b/src/commands/push/devices/list.ts @@ -12,6 +12,11 @@ import { formatCountLabel, formatLimitWarning, } from "../../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../../utils/pagination.js"; export default class PushDevicesList extends AblyBaseCommand { static override description = "List push device registrations"; @@ -60,10 +65,23 @@ export default class PushDevicesList extends AblyBaseCommand { if (flags.state) params.state = flags.state; const result = await rest.push.admin.deviceRegistrations.list(params); - const devices = result.items; + const { + items: devices, + hasMore, + pagesConsumed, + } = await collectPaginatedResults(result, flags.limit); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + devices.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } if (this.shouldOutputJson(flags)) { - this.logJsonResult({ devices }, flags); + const next = buildPaginationNext(hasMore); + this.logJsonResult({ devices, hasMore, ...(next && { next }) }, flags); return; } @@ -104,12 +122,14 @@ export default class PushDevicesList extends AblyBaseCommand { this.log(""); } - const limitWarning = formatLimitWarning( - devices.length, - flags.limit, - "device registrations", - ); - if (limitWarning) this.log(limitWarning); + if (hasMore) { + const limitWarning = formatLimitWarning( + devices.length, + flags.limit, + "device registrations", + ); + if (limitWarning) this.log(limitWarning); + } } catch (error) { this.fail(error, flags as BaseFlags, "pushDeviceList"); } diff --git a/src/commands/queues/list.ts b/src/commands/queues/list.ts index b8e61b8f..60c5f5d3 100644 --- a/src/commands/queues/list.ts +++ b/src/commands/queues/list.ts @@ -1,6 +1,6 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { formatHeading } from "../../utils/output.js"; +import { formatHeading, formatLimitWarning } from "../../utils/output.js"; interface QueueStats { acknowledgementRate: null | number; @@ -56,6 +56,10 @@ export default class QueuesListCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", required: false, }), + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return (default: 100)", + }), }; async run(): Promise { @@ -65,12 +69,15 @@ export default class QueuesListCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - const queues = await controlApi.listQueues(appId); + const allQueues = await controlApi.listQueues(appId); + const hasMore = allQueues.length > flags.limit; + const queues = allQueues.slice(0, flags.limit); if (this.shouldOutputJson(flags)) { this.logJsonResult( { appId, + hasMore, queues: queues.map((queue: Queue) => ({ amqp: queue.amqp, deadletter: queue.deadletter || false, @@ -147,6 +154,15 @@ export default class QueuesListCommand extends ControlBaseCommand { this.log(""); // Add a blank line between queues }); + + if (hasMore) { + const warning = formatLimitWarning( + queues.length, + flags.limit, + "queues", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "queueList", { appId }); diff --git a/src/commands/rooms/list.ts b/src/commands/rooms/list.ts index f1b53821..9f6a9e8a 100644 --- a/src/commands/rooms/list.ts +++ b/src/commands/rooms/list.ts @@ -7,6 +7,11 @@ import { formatResource, formatLabel, } from "../../utils/output.js"; +import { + buildPaginationNext, + collectFilteredPaginatedResults, + formatPaginationWarning, +} from "../../utils/pagination.js"; // Add interface definitions at the beginning of the file interface RoomMetrics { @@ -67,9 +72,12 @@ export default class RoomsList extends ChatBaseCommand { if (!rest) return; // Build params for channel listing - // We request more channels than the limit to account for filtering + // Request 5x the user's limit (capped at the API max of 1000) because + // client-side filtering (only ::$chat channels, deduplicated by room name) + // yields ~1 room per 3-5 raw channels. This minimizes API round trips. + // collectFilteredPaginatedResults fetches additional pages if still needed. const params: RoomListParams = { - limit: flags.limit * 5, // Request more to allow for filtering + limit: Math.min(flags.limit * 5, 1000), }; if (flags.prefix) { @@ -93,58 +101,56 @@ export default class RoomsList extends ChatBaseCommand { ); } - // Filter to only include chat channels - const allChannels = channelsResponse.items || []; - - // Map to store deduplicated rooms - const chatRooms = new Map(); - - // Filter for chat channels and deduplicate - for (const channel of allChannels) { - const { channelId } = channel; - - // Check if this is a chat channel (has ::$chat suffix) - if (channelId.includes("::$chat")) { - // Extract the base room name (everything before the first ::$chat) - // We need to escape the $ in the regex pattern since it's a special character + // Use filtered pagination to collect chat channels, deduplicate by room name + const seenRooms = new Set(); + const { + items: limitedRooms, + hasMore, + pagesConsumed, + } = await collectFilteredPaginatedResults( + channelsResponse, + flags.limit, + (channel: RoomItem) => { + const { channelId } = channel; + if (!channelId.includes("::$chat")) return false; const roomNameMatch = channelId.match(/^(.+?)::\$chat.*$/); - if (roomNameMatch && roomNameMatch[1]) { - const roomName = roomNameMatch[1]; - // Only add if we haven't seen this room before - if (!chatRooms.has(roomName)) { - // Store the original channel data but with the simple room name - const roomData = { - ...channel, - channelId: roomName, - room: roomName, - }; - chatRooms.set(roomName, roomData); - } - } - } - } + if (!roomNameMatch || !roomNameMatch[1]) return false; + const roomName = roomNameMatch[1]; + if (seenRooms.has(roomName)) return false; + seenRooms.add(roomName); + return true; + }, + ); - // Convert map to array - const rooms = [...chatRooms.values()]; + // Normalize names in a separate step (keep filter as pure predicate) + const rooms = limitedRooms.map((r) => { + const match = r.channelId.match(/^(.+?)::\$chat.*$/)!; + return { ...r, channelId: match[1], room: match[1] }; + }); - // Limit the results to the requested number - const limitedRooms = rooms.slice(0, flags.limit); + const paginationWarning = formatPaginationWarning( + pagesConsumed, + rooms.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } // Output rooms based on format if (this.shouldOutputJson(flags)) { - // Wrap the array in an object for formatJsonRecord - this.logJsonResult({ items: limitedRooms }, flags); + const next = buildPaginationNext(hasMore); + this.logJsonResult({ rooms, hasMore, ...(next && { next }) }, flags); } else { - if (limitedRooms.length === 0) { + if (rooms.length === 0) { this.log("No active chat rooms found."); return; } this.log( - `Found ${formatCountLabel(limitedRooms.length, "active chat room")}:`, + `Found ${formatCountLabel(rooms.length, "active chat room")}:`, ); - for (const room of limitedRooms) { + for (const room of rooms) { this.log(`${formatResource(room.room)}`); // Show occupancy if available @@ -176,12 +182,14 @@ export default class RoomsList extends ChatBaseCommand { this.log(""); // Add a line break between rooms } - const warning = formatLimitWarning( - limitedRooms.length, - flags.limit, - "rooms", - ); - if (warning) this.log(warning); + if (hasMore) { + const warning = formatLimitWarning( + rooms.length, + flags.limit, + "rooms", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "roomList"); diff --git a/src/commands/rooms/messages/history.ts b/src/commands/rooms/messages/history.ts index d0483151..4f69cdfc 100644 --- a/src/commands/rooms/messages/history.ts +++ b/src/commands/rooms/messages/history.ts @@ -5,7 +5,9 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { productApiFlags, timeRangeFlags } from "../../../flags.js"; import { + formatIndex, formatLabel, + formatLimitWarning, formatProgress, formatSuccess, formatResource, @@ -14,6 +16,11 @@ import { formatEventType, formatClientId, } from "../../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../../utils/pagination.js"; import { parseTimestamp } from "../../../utils/time.js"; export default class MessagesHistory extends ChatBaseCommand { @@ -134,11 +141,26 @@ export default class MessagesHistory extends ChatBaseCommand { // Get historical messages const messagesResult = await room.messages.history(historyParams); - const { items } = messagesResult; + const { items, hasMore, pagesConsumed } = await collectPaginatedResults( + messagesResult, + flags.limit, + ); + + const paginationWarning = formatPaginationWarning( + pagesConsumed, + items.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } if (this.shouldOutputJson(flags)) { + const lastTimestamp = + items.length > 0 ? items.at(-1)!.timestamp : undefined; + const next = buildPaginationNext(hasMore, lastTimestamp); this.logJsonResult( { + hasMore, messages: items.map((message) => ({ clientId: message.clientId, text: message.text, @@ -147,6 +169,7 @@ export default class MessagesHistory extends ChatBaseCommand { action: String(message.action), ...(message.metadata ? { metadata: message.metadata } : {}), })), + ...(next && { next }), room: args.room, }, flags, @@ -162,12 +185,12 @@ export default class MessagesHistory extends ChatBaseCommand { // Display messages in order provided const messagesInOrder = [...items]; - for (const message of messagesInOrder) { - // Format message with timestamp, author and content + for (let i = 0; i < messagesInOrder.length; i++) { + const message = messagesInOrder[i]; const timestamp = formatMessageTimestamp(message.timestamp); const author = message.clientId || "Unknown"; - this.log(formatTimestamp(timestamp)); + this.log(`${formatIndex(i + 1)} ${formatTimestamp(timestamp)}`); this.log( ` ${formatLabel("Action")} ${formatEventType(String(message.action))}`, ); @@ -185,6 +208,15 @@ export default class MessagesHistory extends ChatBaseCommand { } } } + + if (hasMore) { + const warning = formatLimitWarning( + items.length, + flags.limit, + "messages", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "roomMessageHistory", { room: args.room }); diff --git a/src/commands/spaces/list.ts b/src/commands/spaces/list.ts index 3c1b2e3a..dd587389 100644 --- a/src/commands/spaces/list.ts +++ b/src/commands/spaces/list.ts @@ -7,6 +7,11 @@ import { formatLimitWarning, formatResource, } from "../../utils/output.js"; +import { + buildPaginationNext, + collectFilteredPaginatedResults, + formatPaginationWarning, +} from "../../utils/pagination.js"; import { SpacesBaseCommand } from "../../spaces-base-command.js"; interface SpaceMetrics { @@ -62,14 +67,12 @@ export default class SpacesList extends SpacesBaseCommand { if (!rest) return; // Build params for channel listing - // We request more channels than the limit to account for filtering - interface ChannelParams { - limit: number; - prefix?: string; - } - - const params: ChannelParams = { - limit: flags.limit * 5, // Request more to allow for filtering + // Request 5x the user's limit (capped at the API max of 1000) because + // client-side filtering (only ::$space channels, deduplicated by space name) + // yields ~1 space per 3-5 raw channels. This minimizes API round trips. + // collectFilteredPaginatedResults fetches additional pages if still needed. + const params: { limit: number; prefix?: string } = { + limit: Math.min(flags.limit * 5, 1000), }; if (flags.prefix) { @@ -92,68 +95,65 @@ export default class SpacesList extends SpacesBaseCommand { ); } - // Filter to only include space channels - const allChannels = channelsResponse.items || []; - - // Map to store deduplicated spaces - const spaces = new Map(); - - // Filter for space channels and deduplicate - for (const channel of allChannels) { - const { channelId } = channel; - - // Check if this is a space channel (has ::$space suffix) - if (channelId.includes("::$space")) { - // Extract the base space name (everything before the first ::$space) - // We need to escape the $ in the regex pattern since it's a special character - const spaceNameMatch = channelId.match(/^(.+?)::\$space.*$/); - if (spaceNameMatch && spaceNameMatch[1]) { - const spaceName = spaceNameMatch[1]; - // Only add if we haven't seen this space before - if (!spaces.has(spaceName)) { - // Store the original channel data but with the simple space name - const spaceData: SpaceItem = { - ...channel, - channelId: spaceName, - spaceName, - }; - spaces.set(spaceName, spaceData); - } - } - } - } + // Use filtered pagination to collect space channels, deduplicate by space name + const seenSpaces = new Set(); + const { + items: limitedSpaces, + hasMore, + pagesConsumed, + } = await collectFilteredPaginatedResults( + channelsResponse, + flags.limit, + (channel: SpaceItem) => { + const { channelId } = channel; + if (!channelId) return false; + const spaceNameMatch = channelId.match(/^(.+?)::\$space(?:$|::)/); + if (!spaceNameMatch || !spaceNameMatch[1]) return false; + const spaceName = spaceNameMatch[1]; + if (seenSpaces.has(spaceName)) return false; + seenSpaces.add(spaceName); + return true; + }, + ); - // Convert map to array - const spacesList = [...spaces.values()]; + // Normalize names in a separate step (keep filter as pure predicate) + const spaces = limitedSpaces.map((s) => { + const match = s.channelId!.match(/^(.+?)::\$space(?:$|::)/)!; + return { ...s, channelId: match[1], spaceName: match[1] }; + }); - // Limit the results to the requested number - const limitedSpaces = spacesList.slice(0, flags.limit); + const paginationWarning = formatPaginationWarning( + pagesConsumed, + spaces.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } if (this.shouldOutputJson(flags)) { + const next = buildPaginationNext(hasMore); this.logJsonResult( { - hasMore: spacesList.length > flags.limit, - shown: limitedSpaces.length, - spaces: limitedSpaces.map((space: SpaceItem) => ({ + hasMore, + ...(next && { next }), + spaces: spaces.map((space) => ({ metrics: space.status?.occupancy?.metrics || {}, spaceName: space.spaceName, })), timestamp: new Date().toISOString(), - total: spacesList.length, + total: spaces.length, }, flags, ); } else { - if (limitedSpaces.length === 0) { + if (spaces.length === 0 && !hasMore) { this.log("No active spaces found."); return; } - this.log( - `Found ${formatCountLabel(limitedSpaces.length, "active space")}:`, - ); + this.log(`Found ${formatCountLabel(spaces.length, "active space")}:`); - limitedSpaces.forEach((space: SpaceItem) => { + spaces.forEach((space) => { this.log(`${formatResource(space.spaceName)}`); // Show occupancy if available @@ -185,12 +185,14 @@ export default class SpacesList extends SpacesBaseCommand { this.log(""); // Add a line break between spaces }); - const warning = formatLimitWarning( - limitedSpaces.length, - flags.limit, - "spaces", - ); - if (warning) this.log(warning); + if (hasMore) { + const warning = formatLimitWarning( + spaces.length, + flags.limit, + "spaces", + ); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "spaceList"); diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 00000000..99cf7642 --- /dev/null +++ b/src/utils/pagination.ts @@ -0,0 +1,142 @@ +/** + * Generic pagination utilities for collecting results across multiple pages. + * + * Works with both Ably SDK PaginatedResult and Chat SDK PaginatedResult + * (same interface: items, hasNext(), next()). + */ + +import { formatWarning } from "./output.js"; + +export interface PaginationResult { + items: T[]; + hasMore: boolean; + pagesConsumed: number; +} + +export interface PaginationNext { + hint: string; + start?: string; +} + +/** + * Build a `next` object for JSON output when `hasMore` is true. + * For history commands, pass `lastTimestamp` to include a `start` value + * that users can pass to `--start` to continue from where they left off. + */ +export function buildPaginationNext( + hasMore: boolean, + lastTimestamp?: Date | number, +): PaginationNext | undefined { + if (!hasMore) return undefined; + + const next: PaginationNext = { + hint: "Increase --limit to fetch more results, or use --start to continue from a specific point in time", + }; + + if (lastTimestamp !== undefined) { + const ts = + lastTimestamp instanceof Date + ? lastTimestamp.toISOString() + : new Date(lastTimestamp).toISOString(); + next.start = ts; + next.hint = `Use --start "${ts}" to continue from where this result set ended, or increase --limit`; + } + + return next; +} + +/** + * Returns a formatted warning when multiple pages were fetched, or empty string if only one page. + */ +export function formatPaginationWarning( + pagesConsumed: number, + itemCount: number, +): string { + if (pagesConsumed <= 1) return ""; + return formatWarning( + `Fetched ${pagesConsumed} pages to retrieve ${itemCount} results. Each result counts as a billable message.`, + ); +} + +interface PaginatedPage { + items: T[]; + hasNext(): boolean; + next(): Promise | null>; +} + +/** + * Collect items from a paginated result until the limit is reached or no more pages. + * Truncates to `limit` items and reports whether more data exists. + */ +export async function collectPaginatedResults( + firstPage: PaginatedPage, + limit: number, +): Promise> { + if (!Number.isInteger(limit) || limit < 1) { + throw new Error("Pagination limit must be a positive integer."); + } + + const items: T[] = [...firstPage.items]; + let pagesConsumed = 1; + + let currentPage: PaginatedPage = firstPage; + + while (items.length < limit && currentPage.hasNext()) { + const nextPage = await currentPage.next(); + if (!nextPage) break; + pagesConsumed++; + items.push(...nextPage.items); + currentPage = nextPage; + } + + const hasMore = + items.length > limit || (items.length >= limit && currentPage.hasNext()); + const truncated = items.slice(0, limit); + + return { + items: truncated, + hasMore: hasMore || truncated.length < items.length, + pagesConsumed, + }; +} + +/** + * Collect filtered items from paginated results. Keeps fetching pages until enough + * items matching the filter are found, with a max-pages safety cap. + */ +export async function collectFilteredPaginatedResults( + firstPage: PaginatedPage, + limit: number, + filter: (item: T) => boolean, + maxPages = 20, +): Promise> { + if (!Number.isInteger(limit) || limit < 1) { + throw new Error("Pagination limit must be a positive integer."); + } + + const items: T[] = []; + let pagesConsumed = 0; + let currentPage: PaginatedPage | null = firstPage; + + while (currentPage && items.length < limit && pagesConsumed < maxPages) { + pagesConsumed++; + for (const item of currentPage.items) { + if (filter(item)) { + items.push(item); + } + } + + if (items.length >= limit || !currentPage.hasNext()) break; + currentPage = await currentPage.next(); + } + + const hasMore = + items.length > limit || (currentPage !== null && currentPage.hasNext()); + const truncated = items.slice(0, limit); + + return { + items: truncated, + hasMore: hasMore || truncated.length < items.length, + pagesConsumed, + }; +} diff --git a/test/helpers/mock-ably-chat.ts b/test/helpers/mock-ably-chat.ts index b4993f2f..ed339ffc 100644 --- a/test/helpers/mock-ably-chat.ts +++ b/test/helpers/mock-ably-chat.ts @@ -29,6 +29,7 @@ */ import { vi, type Mock } from "vitest"; +import { createMockPaginatedResult } from "./mock-ably-rest.js"; import { RoomStatus, type Message, @@ -57,6 +58,7 @@ export interface MockRoomMessages { get: Mock; update: Mock; delete: Mock; + history: Mock; reactions: MockMessageReactions; // Internal emitter for simulating events _emitter: AblyEventEmitter; @@ -252,6 +254,7 @@ function createMockRoomMessages(): MockRoomMessages { timestamp: new Date(), version: { serial: "mock-version-serial", timestamp: new Date() }, }), + history: vi.fn().mockResolvedValue(createMockPaginatedResult([])), reactions: createMockMessageReactions(), _emitter: emitter, _emit: (message: Message) => { diff --git a/test/helpers/mock-ably-rest.ts b/test/helpers/mock-ably-rest.ts index 073974a6..9759f969 100644 --- a/test/helpers/mock-ably-rest.ts +++ b/test/helpers/mock-ably-rest.ts @@ -118,8 +118,8 @@ export interface MockAblyRest { */ function createMockRestPresence(): MockRestPresence { return { - get: vi.fn().mockResolvedValue({ items: [] }), - history: vi.fn().mockResolvedValue({ items: [] }), + get: vi.fn().mockResolvedValue(createMockPaginatedResult([])), + history: vi.fn().mockResolvedValue(createMockPaginatedResult([])), }; } @@ -141,7 +141,7 @@ function createMockRestChannel(name: string): MockRestChannel { return { name, publish: vi.fn().mockResolvedValue({ serials: ["mock-serial-001"] }), - history: vi.fn().mockResolvedValue({ items: [] }), + history: vi.fn().mockResolvedValue(createMockPaginatedResult([])), updateMessage: vi .fn() .mockResolvedValue({ versionSerial: "mock-version-serial-update" }), @@ -219,14 +219,14 @@ function createMockPush(): MockPush { admin: { publish: vi.fn().mockImplementation(async () => {}), channelSubscriptions: { - list: vi.fn().mockResolvedValue({ items: [] }), - listChannels: vi.fn().mockResolvedValue({ items: [] }), + list: vi.fn().mockResolvedValue(createMockPaginatedResult([])), + listChannels: vi.fn().mockResolvedValue(createMockPaginatedResult([])), save: vi.fn().mockImplementation(async () => {}), remove: vi.fn().mockImplementation(async () => {}), removeWhere: vi.fn().mockImplementation(async () => {}), }, deviceRegistrations: { - list: vi.fn().mockResolvedValue({ items: [] }), + list: vi.fn().mockResolvedValue(createMockPaginatedResult([])), get: vi.fn().mockResolvedValue(null), save: vi.fn().mockImplementation(async () => {}), remove: vi.fn().mockImplementation(async () => {}), @@ -248,7 +248,7 @@ function createMockAblyRest(): MockAblyRest { channels, auth, request: vi.fn().mockResolvedValue({ - items: [], + ...createMockPaginatedResult([]), statusCode: 200, success: true, }), @@ -266,6 +266,42 @@ function createMockAblyRest(): MockAblyRest { return mock; } +/** + * Create a mock PaginatedResult-like object for testing pagination. + * Supports arbitrary page chains: pass additional arrays for subsequent pages. + * + * @example + * createMockPaginatedResult([1, 2]) // single page + * createMockPaginatedResult([1, 2], [3, 4]) // two pages + * createMockPaginatedResult([1, 2], [3, 4], [5, 6]) // three pages + */ +export function createMockPaginatedResult( + items: T[], + ...remainingPages: T[][] +): { + items: T[]; + hasNext: () => boolean; + next: () => Promise> | null>; + isLast: () => boolean; + first: () => Promise>>; + current: () => Promise>>; +} { + const hasNextPage = remainingPages.length > 0; + const result = { + items, + hasNext: () => hasNextPage, + next: async () => { + if (!hasNextPage) return null; + const [nextItems, ...rest] = remainingPages; + return createMockPaginatedResult(nextItems, ...rest); + }, + isLast: () => !hasNextPage, + first: async () => result, + current: async () => result, + }; + return result; +} + // Singleton instance let mockInstance: MockAblyRest | null = null; diff --git a/test/unit/commands/channels/history.test.ts b/test/unit/commands/channels/history.test.ts index f65742d4..672ff574 100644 --- a/test/unit/commands/channels/history.test.ts +++ b/test/unit/commands/channels/history.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -12,8 +15,8 @@ describe("channels:history command", () => { // Configure the centralized mock with test data const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - channel.history.mockResolvedValue({ - items: [ + channel.history.mockResolvedValue( + createMockPaginatedResult([ { id: "msg-1", name: "test-event", @@ -47,8 +50,8 @@ describe("channels:history command", () => { clientId: "client-2", connectionId: "conn-2", }, - ], - }); + ]), + ); }); standardHelpTests("channels:history", import.meta.url); @@ -119,8 +122,8 @@ describe("channels:history command", () => { it("should display message versioning metadata", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - channel.history.mockResolvedValue({ - items: [ + channel.history.mockResolvedValue( + createMockPaginatedResult([ { name: "test-event", data: "hello", @@ -131,8 +134,8 @@ describe("channels:history command", () => { serial: "version-serial-001", }, }, - ], - }); + ]), + ); const { stdout } = await runCommand( ["channels:history", "test-channel"], @@ -147,7 +150,7 @@ describe("channels:history command", () => { it("should handle empty history", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); const { stdout } = await runCommand( ["channels:history", "test-channel"], diff --git a/test/unit/commands/channels/list.test.ts b/test/unit/commands/channels/list.test.ts index 57f67850..0f923e7b 100644 --- a/test/unit/commands/channels/list.test.ts +++ b/test/unit/commands/channels/list.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -10,8 +13,7 @@ import { describe("channels:list command", () => { // Mock channel response data const mockChannelsResponse = { - statusCode: 200, - items: [ + ...createMockPaginatedResult([ { channelId: "test-channel-1", status: { @@ -38,7 +40,8 @@ describe("channels:list command", () => { }, }, }, - ], + ]), + statusCode: 200, }; beforeEach(() => { @@ -81,7 +84,10 @@ describe("channels:list command", () => { it("should handle empty channels response", async () => { const mock = getMockAblyRest(); - mock.request.mockResolvedValue({ statusCode: 200, items: [] }); + mock.request.mockResolvedValue({ + ...createMockPaginatedResult([]), + statusCode: 200, + }); const { stdout } = await runCommand(["channels:list"], import.meta.url); diff --git a/test/unit/commands/logs/connection-lifecycle/history.test.ts b/test/unit/commands/logs/connection-lifecycle/history.test.ts index 78d02f8d..d1331a34 100644 --- a/test/unit/commands/logs/connection-lifecycle/history.test.ts +++ b/test/unit/commands/logs/connection-lifecycle/history.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -11,8 +14,8 @@ describe("logs:connection-lifecycle:history command", () => { beforeEach(() => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]connection.lifecycle"); - channel.history.mockResolvedValue({ - items: [ + channel.history.mockResolvedValue( + createMockPaginatedResult([ { id: "msg-1", name: "connection.opened", @@ -21,8 +24,8 @@ describe("logs:connection-lifecycle:history command", () => { clientId: "client-1", connectionId: "conn-1", }, - ], - }); + ]), + ); }); standardHelpTests("logs:connection-lifecycle:history", import.meta.url); @@ -70,7 +73,7 @@ describe("logs:connection-lifecycle:history command", () => { it("should handle empty history", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]connection.lifecycle"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); const { stdout } = await runCommand( ["logs:connection-lifecycle:history"], diff --git a/test/unit/commands/logs/history.test.ts b/test/unit/commands/logs/history.test.ts index 15b37d94..6ad0ae98 100644 --- a/test/unit/commands/logs/history.test.ts +++ b/test/unit/commands/logs/history.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -36,7 +39,7 @@ describe("logs:history command", () => { it("should pass --start to history params", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); const start = "2023-06-01T00:00:00Z"; await runCommand(["logs:history", "--start", start], import.meta.url); @@ -51,7 +54,7 @@ describe("logs:history command", () => { it("should error when --start is after --end", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); const { error } = await runCommand( [ @@ -73,7 +76,7 @@ describe("logs:history command", () => { it("should pass --direction to history params", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); await runCommand( ["logs:history", "--direction", "forwards"], @@ -88,7 +91,7 @@ describe("logs:history command", () => { it("should default to backwards direction", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); await runCommand(["logs:history"], import.meta.url); @@ -100,7 +103,7 @@ describe("logs:history command", () => { it("should pass --limit to history params", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); await runCommand(["logs:history", "--limit", "50"], import.meta.url); @@ -122,7 +125,9 @@ describe("logs:history command", () => { clientId: "client-1", connectionId: "conn-1", })); - channel.history.mockResolvedValue({ items: messages }); + channel.history.mockResolvedValue( + createMockPaginatedResult(messages, [{ id: "more" }]), + ); const { stdout } = await runCommand( ["logs:history", "--limit", "10"], @@ -136,7 +141,7 @@ describe("logs:history command", () => { it("should show 'No application logs found' on empty results", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); const { stdout } = await runCommand(["logs:history"], import.meta.url); diff --git a/test/unit/commands/logs/push/history.test.ts b/test/unit/commands/logs/push/history.test.ts index 0b71bf2a..ccfe177b 100644 --- a/test/unit/commands/logs/push/history.test.ts +++ b/test/unit/commands/logs/push/history.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -11,8 +14,8 @@ describe("logs:push:history command", () => { beforeEach(() => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log:push"); - channel.history.mockResolvedValue({ - items: [ + channel.history.mockResolvedValue( + createMockPaginatedResult([ { id: "msg-1", name: "push.delivered", @@ -21,8 +24,8 @@ describe("logs:push:history command", () => { clientId: "client-1", connectionId: "conn-1", }, - ], - }); + ]), + ); }); standardHelpTests("logs:push:history", import.meta.url); @@ -67,7 +70,7 @@ describe("logs:push:history command", () => { it("should handle empty history", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("[meta]log:push"); - channel.history.mockResolvedValue({ items: [] }); + channel.history.mockResolvedValue(createMockPaginatedResult([])); const { stdout } = await runCommand( ["logs:push:history"], diff --git a/test/unit/commands/push/channels/list-channels.test.ts b/test/unit/commands/push/channels/list-channels.test.ts index a44bba1f..dd100bec 100644 --- a/test/unit/commands/push/channels/list-channels.test.ts +++ b/test/unit/commands/push/channels/list-channels.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -22,9 +25,9 @@ describe("push:channels:list-channels command", () => { describe("functionality", () => { it("should list channels with push subscriptions", async () => { const mock = getMockAblyRest(); - mock.push.admin.channelSubscriptions.listChannels.mockResolvedValue({ - items: ["channel-1", "channel-2", "channel-3"], - }); + mock.push.admin.channelSubscriptions.listChannels.mockResolvedValue( + createMockPaginatedResult(["channel-1", "channel-2", "channel-3"]), + ); const { stdout } = await runCommand( ["push:channels:list-channels"], @@ -38,9 +41,9 @@ describe("push:channels:list-channels command", () => { it("should handle empty list", async () => { const mock = getMockAblyRest(); - mock.push.admin.channelSubscriptions.listChannels.mockResolvedValue({ - items: [], - }); + mock.push.admin.channelSubscriptions.listChannels.mockResolvedValue( + createMockPaginatedResult([]), + ); const { stdout } = await runCommand( ["push:channels:list-channels"], @@ -52,9 +55,9 @@ describe("push:channels:list-channels command", () => { it("should output JSON when requested", async () => { const mock = getMockAblyRest(); - mock.push.admin.channelSubscriptions.listChannels.mockResolvedValue({ - items: ["channel-1"], - }); + mock.push.admin.channelSubscriptions.listChannels.mockResolvedValue( + createMockPaginatedResult(["channel-1"]), + ); const { stdout } = await runCommand( ["push:channels:list-channels", "--json"], diff --git a/test/unit/commands/push/channels/list.test.ts b/test/unit/commands/push/channels/list.test.ts index a635140d..0f25b9d7 100644 --- a/test/unit/commands/push/channels/list.test.ts +++ b/test/unit/commands/push/channels/list.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -25,12 +28,12 @@ describe("push:channels:list command", () => { describe("functionality", () => { it("should list subscriptions for a channel", async () => { const mock = getMockAblyRest(); - mock.push.admin.channelSubscriptions.list.mockResolvedValue({ - items: [ + mock.push.admin.channelSubscriptions.list.mockResolvedValue( + createMockPaginatedResult([ { channel: "my-channel", deviceId: "device-1" }, { channel: "my-channel", clientId: "client-1" }, - ], - }); + ]), + ); const { stdout } = await runCommand( ["push:channels:list", "--channel", "my-channel"], @@ -43,23 +46,25 @@ describe("push:channels:list command", () => { it("should handle empty list", async () => { const mock = getMockAblyRest(); - mock.push.admin.channelSubscriptions.list.mockResolvedValue({ - items: [], - }); + mock.push.admin.channelSubscriptions.list.mockResolvedValue( + createMockPaginatedResult([]), + ); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:channels:list", "--channel", "my-channel"], import.meta.url, ); - expect(stdout).toContain("No subscriptions found"); + expect(stderr).toContain("No subscriptions found"); }); it("should output JSON when requested", async () => { const mock = getMockAblyRest(); - mock.push.admin.channelSubscriptions.list.mockResolvedValue({ - items: [{ channel: "my-channel", deviceId: "device-1" }], - }); + mock.push.admin.channelSubscriptions.list.mockResolvedValue( + createMockPaginatedResult([ + { channel: "my-channel", deviceId: "device-1" }, + ]), + ); const { stdout } = await runCommand( ["push:channels:list", "--channel", "my-channel", "--json"], diff --git a/test/unit/commands/push/devices/list.test.ts b/test/unit/commands/push/devices/list.test.ts index 8561c829..bef4473f 100644 --- a/test/unit/commands/push/devices/list.test.ts +++ b/test/unit/commands/push/devices/list.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, @@ -25,8 +28,8 @@ describe("push:devices:list command", () => { describe("functionality", () => { it("should list devices successfully", async () => { const mock = getMockAblyRest(); - mock.push.admin.deviceRegistrations.list.mockResolvedValue({ - items: [ + mock.push.admin.deviceRegistrations.list.mockResolvedValue( + createMockPaginatedResult([ { id: "device-1", platform: "ios", @@ -37,8 +40,8 @@ describe("push:devices:list command", () => { recipient: { transportType: "apns" }, }, }, - ], - }); + ]), + ); const { stdout } = await runCommand( ["push:devices:list"], @@ -52,9 +55,9 @@ describe("push:devices:list command", () => { it("should handle empty list", async () => { const mock = getMockAblyRest(); - mock.push.admin.deviceRegistrations.list.mockResolvedValue({ - items: [], - }); + mock.push.admin.deviceRegistrations.list.mockResolvedValue( + createMockPaginatedResult([]), + ); const { stdout } = await runCommand( ["push:devices:list"], @@ -66,9 +69,9 @@ describe("push:devices:list command", () => { it("should output JSON when requested", async () => { const mock = getMockAblyRest(); - mock.push.admin.deviceRegistrations.list.mockResolvedValue({ - items: [{ id: "device-1", platform: "ios" }], - }); + mock.push.admin.deviceRegistrations.list.mockResolvedValue( + createMockPaginatedResult([{ id: "device-1", platform: "ios" }]), + ); const { stdout } = await runCommand( ["push:devices:list", "--json"], @@ -79,13 +82,33 @@ describe("push:devices:list command", () => { expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("devices"); + expect(result).toHaveProperty("hasMore", false); + }); + + it("should report hasMore and pagination warning with multi-page results", async () => { + const mock = getMockAblyRest(); + mock.push.admin.deviceRegistrations.list.mockResolvedValue( + createMockPaginatedResult( + [{ id: "device-1", platform: "ios" }], + [{ id: "device-2", platform: "android" }], + ), + ); + + const { stdout } = await runCommand( + ["push:devices:list", "--json", "--limit", "10"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("hasMore", false); + expect(result.devices).toHaveLength(2); }); it("should pass filter params to SDK", async () => { const mock = getMockAblyRest(); - mock.push.admin.deviceRegistrations.list.mockResolvedValue({ - items: [], - }); + mock.push.admin.deviceRegistrations.list.mockResolvedValue( + createMockPaginatedResult([]), + ); await runCommand( [ diff --git a/test/unit/commands/rooms/list.test.ts b/test/unit/commands/rooms/list.test.ts index db22a617..7838da10 100644 --- a/test/unit/commands/rooms/list.test.ts +++ b/test/unit/commands/rooms/list.test.ts @@ -1,12 +1,54 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +function createMockChatChannelItems() { + return [ + { + channelId: "room1::$chat::$chatMessages", + status: { + occupancy: { + metrics: { connections: 5, publishers: 2, subscribers: 3 }, + }, + }, + }, + { + channelId: "room1::$chat::$chatMessages::$reactions", + status: { + occupancy: { metrics: { connections: 5 } }, + }, + }, + { + channelId: "room1::$chat::$typingIndicators", + status: { + occupancy: { metrics: { connections: 3 } }, + }, + }, + { + channelId: "room2::$chat::$chatMessages", + status: { + occupancy: { + metrics: { connections: 2, publishers: 1, subscribers: 0 }, + }, + }, + }, + { + channelId: "regular-channel", + status: { + occupancy: { metrics: { connections: 1 } }, + }, + }, + ]; +} + describe("rooms:list command", () => { standardHelpTests("rooms:list", import.meta.url); standardArgValidationTests("rooms:list", import.meta.url); @@ -16,49 +58,12 @@ describe("rooms:list command", () => { "--prefix", ]); - const mockChatChannelsResponse = { - statusCode: 200, - items: [ - { - channelId: "room1::$chat::$chatMessages", - status: { - occupancy: { - metrics: { connections: 5, publishers: 2, subscribers: 3 }, - }, - }, - }, - { - channelId: "room1::$chat::$chatMessages::$reactions", - status: { - occupancy: { metrics: { connections: 5 } }, - }, - }, - { - channelId: "room1::$chat::$typingIndicators", - status: { - occupancy: { metrics: { connections: 3 } }, - }, - }, - { - channelId: "room2::$chat::$chatMessages", - status: { - occupancy: { - metrics: { connections: 2, publishers: 1, subscribers: 0 }, - }, - }, - }, - { - channelId: "regular-channel", - status: { - occupancy: { metrics: { connections: 1 } }, - }, - }, - ], - }; - beforeEach(() => { const mock = getMockAblyRest(); - mock.request.mockResolvedValue(mockChatChannelsResponse); + mock.request.mockResolvedValue({ + ...createMockPaginatedResult(createMockChatChannelItems()), + statusCode: 200, + }); }); it("should filter to ::$chat channels only", async () => { @@ -106,7 +111,10 @@ describe("rooms:list command", () => { it("should show 'No active chat rooms' on empty response", async () => { const mock = getMockAblyRest(); - mock.request.mockResolvedValue({ statusCode: 200, items: [] }); + mock.request.mockResolvedValue({ + ...createMockPaginatedResult([]), + statusCode: 200, + }); const { stdout } = await runCommand(["rooms:list"], import.meta.url); @@ -128,11 +136,11 @@ describe("rooms:list command", () => { ); const json = JSON.parse(stdout); - expect(json).toHaveProperty("items"); - expect(json.items).toBeInstanceOf(Array); - expect(json.items.length).toBe(2); - expect(json.items[0]).toHaveProperty("room", "room1"); - expect(json.items[1]).toHaveProperty("room", "room2"); + expect(json).toHaveProperty("rooms"); + expect(json.rooms).toBeInstanceOf(Array); + expect(json.rooms.length).toBe(2); + expect(json.rooms[0]).toHaveProperty("room", "room1"); + expect(json.rooms[1]).toHaveProperty("room", "room2"); }); it("should handle non-200 response with error", async () => { diff --git a/test/unit/commands/rooms/messages.test.ts b/test/unit/commands/rooms/messages.test.ts index 9f23079e..c65e69ef 100644 --- a/test/unit/commands/rooms/messages.test.ts +++ b/test/unit/commands/rooms/messages.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblyChat } from "../../../helpers/mock-ably-chat.js"; +import { createMockPaginatedResult } from "../../../helpers/mock-ably-rest.js"; import { captureJsonLogs } from "../../../helpers/ndjson.js"; describe("rooms messages commands", function () { @@ -359,8 +360,8 @@ describe("rooms messages commands", function () { const room = chatMock.rooms._getRoom("test-room"); // Add history mock to messages - room.messages.history = vi.fn().mockResolvedValue({ - items: [ + room.messages.history = vi.fn().mockResolvedValue( + createMockPaginatedResult([ { text: "Historical message 1", clientId: "client1", @@ -373,8 +374,8 @@ describe("rooms messages commands", function () { timestamp: new Date(Date.now() - 5000), serial: "msg-2", }, - ], - }); + ]), + ); const { stdout } = await runCommand( ["rooms:messages:history", "test-room"], @@ -390,7 +391,9 @@ describe("rooms messages commands", function () { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + room.messages.history = vi + .fn() + .mockResolvedValue(createMockPaginatedResult([])); await runCommand( [ @@ -416,7 +419,9 @@ describe("rooms messages commands", function () { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + room.messages.history = vi + .fn() + .mockResolvedValue(createMockPaginatedResult([])); const start = "2025-01-01T00:00:00Z"; const end = "2025-01-02T00:00:00Z"; @@ -445,7 +450,9 @@ describe("rooms messages commands", function () { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + room.messages.history = vi + .fn() + .mockResolvedValue(createMockPaginatedResult([])); await runCommand( ["rooms:messages:history", "test-room", "--start", "1700000000000"], @@ -463,7 +470,9 @@ describe("rooms messages commands", function () { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + room.messages.history = vi + .fn() + .mockResolvedValue(createMockPaginatedResult([])); await runCommand( ["rooms:messages:history", "test-room", "--start", "1h"], @@ -480,16 +489,16 @@ describe("rooms messages commands", function () { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ - items: [ + room.messages.history = vi.fn().mockResolvedValue( + createMockPaginatedResult([ { text: "History msg", clientId: "client1", timestamp: new Date(Date.now() - 5000), serial: "msg-h1", }, - ], - }); + ]), + ); const records = await captureJsonLogs(async () => { await runCommand( diff --git a/test/unit/commands/rooms/messages/history.test.ts b/test/unit/commands/rooms/messages/history.test.ts index cc1b5542..6e8151c6 100644 --- a/test/unit/commands/rooms/messages/history.test.ts +++ b/test/unit/commands/rooms/messages/history.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; +import { createMockPaginatedResult } from "../../../../helpers/mock-ably-rest.js"; import { captureJsonLogs } from "../../../../helpers/ndjson.js"; import { standardHelpTests, @@ -31,8 +32,8 @@ describe("rooms:messages:history command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ - items: [ + room.messages.history = vi.fn().mockResolvedValue( + createMockPaginatedResult([ { text: "Historical message 1", clientId: "client1", @@ -47,8 +48,8 @@ describe("rooms:messages:history command", () => { serial: "msg-2", action: "message.updated", }, - ], - }); + ]), + ); const { stdout } = await runCommand( ["rooms:messages:history", "test-room"], @@ -69,7 +70,9 @@ describe("rooms:messages:history command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + room.messages.history = vi + .fn() + .mockResolvedValue(createMockPaginatedResult([])); const { stdout } = await runCommand( ["rooms:messages:history", "test-room"], @@ -85,8 +88,8 @@ describe("rooms:messages:history command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ - items: [ + room.messages.history = vi.fn().mockResolvedValue( + createMockPaginatedResult([ { text: "Msg with metadata", clientId: "client1", @@ -95,8 +98,8 @@ describe("rooms:messages:history command", () => { action: "message.created", metadata: { priority: "high" }, }, - ], - }); + ]), + ); const { stdout } = await runCommand( ["rooms:messages:history", "test-room", "--show-metadata"], @@ -111,8 +114,8 @@ describe("rooms:messages:history command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ - items: [ + room.messages.history = vi.fn().mockResolvedValue( + createMockPaginatedResult([ { text: "History msg", clientId: "client1", @@ -121,8 +124,8 @@ describe("rooms:messages:history command", () => { action: "message.created", metadata: { key: "value" }, }, - ], - }); + ]), + ); const records = await captureJsonLogs(async () => { await runCommand( @@ -170,7 +173,9 @@ describe("rooms:messages:history command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + room.messages.history = vi + .fn() + .mockResolvedValue(createMockPaginatedResult([])); const { error } = await runCommand( [ diff --git a/test/unit/commands/spaces/list.test.ts b/test/unit/commands/spaces/list.test.ts index 60919eb2..9488a6f9 100644 --- a/test/unit/commands/spaces/list.test.ts +++ b/test/unit/commands/spaces/list.test.ts @@ -1,51 +1,56 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; -describe("spaces:list command", () => { - const mockSpaceChannelsResponse = { - statusCode: 200, - items: [ - { - channelId: "space1::$space::$locks", - status: { - occupancy: { - metrics: { connections: 3, publishers: 1, subscribers: 2 }, - }, +function createMockSpaceChannelItems() { + return [ + { + channelId: "space1::$space::$locks", + status: { + occupancy: { + metrics: { connections: 3, publishers: 1, subscribers: 2 }, }, }, - { - channelId: "space1::$space::$cursors", - status: { - occupancy: { metrics: { connections: 2 } }, - }, + }, + { + channelId: "space1::$space::$cursors", + status: { + occupancy: { metrics: { connections: 2 } }, }, - { - channelId: "space2::$space::$locks", - status: { - occupancy: { - metrics: { connections: 1, publishers: 0, subscribers: 1 }, - }, + }, + { + channelId: "space2::$space::$locks", + status: { + occupancy: { + metrics: { connections: 1, publishers: 0, subscribers: 1 }, }, }, - { - channelId: "regular-channel", - status: { - occupancy: { metrics: { connections: 1 } }, - }, + }, + { + channelId: "regular-channel", + status: { + occupancy: { metrics: { connections: 1 } }, }, - ], - }; + }, + ]; +} +describe("spaces:list command", () => { beforeEach(() => { const mock = getMockAblyRest(); mock.request.mockClear(); - mock.request.mockResolvedValue(mockSpaceChannelsResponse); + mock.request.mockResolvedValue({ + ...createMockPaginatedResult(createMockSpaceChannelItems()), + statusCode: 200, + }); }); it("should filter to ::$space channels only", async () => { @@ -92,7 +97,10 @@ describe("spaces:list command", () => { it("should show 'No active spaces' on empty response", async () => { const mock = getMockAblyRest(); - mock.request.mockResolvedValue({ statusCode: 200, items: [] }); + mock.request.mockResolvedValue({ + ...createMockPaginatedResult([]), + statusCode: 200, + }); const { stdout } = await runCommand(["spaces:list"], import.meta.url); @@ -108,7 +116,6 @@ describe("spaces:list command", () => { const json = JSON.parse(stdout); expect(json).toHaveProperty("spaces"); expect(json).toHaveProperty("total"); - expect(json).toHaveProperty("shown"); expect(json).toHaveProperty("hasMore"); expect(json).toHaveProperty("success", true); expect(json.spaces).toBeInstanceOf(Array); @@ -135,6 +142,46 @@ describe("spaces:list command", () => { }); }); + it("should deduplicate across multiple pages and show pagination warning", async () => { + const mock = getMockAblyRest(); + const page1 = [ + { + channelId: "space1::$space::$locks", + status: { occupancy: { metrics: { connections: 1 } } }, + }, + { + channelId: "space1::$space::$cursors", + status: { occupancy: { metrics: { connections: 1 } } }, + }, + ]; + const page2 = [ + { + channelId: "space1::$space::$locations", + status: { occupancy: { metrics: { connections: 1 } } }, + }, + { + channelId: "space2::$space::$locks", + status: { occupancy: { metrics: { connections: 2 } } }, + }, + ]; + mock.request.mockResolvedValue({ + ...createMockPaginatedResult(page1, page2), + statusCode: 200, + }); + + const { stdout } = await runCommand( + ["spaces:list", "--limit", "10"], + import.meta.url, + ); + + // space1 appears on both pages but should be deduplicated + expect(stdout).toContain("space1"); + expect(stdout).toContain("space2"); + expect(stdout).toContain("2 active spaces"); + // Pagination warning for multi-page fetch + expect(stdout).toContain("pages"); + }); + describe("error handling", () => { it("should handle errors gracefully", async () => { const mock = getMockAblyRest(); diff --git a/test/unit/utils/pagination.test.ts b/test/unit/utils/pagination.test.ts new file mode 100644 index 00000000..c043f4ff --- /dev/null +++ b/test/unit/utils/pagination.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; + +import { + collectPaginatedResults, + collectFilteredPaginatedResults, +} from "../../../src/utils/pagination.js"; + +function createMockPage( + items: T[], + nextPage?: { + items: T[]; + hasNext?: boolean; + nextPage?: { items: T[]; hasNext?: boolean }; + }, +) { + return { + items, + hasNext: () => !!nextPage, + next: async () => { + if (!nextPage) return null; + return createMockPage( + nextPage.items, + nextPage.nextPage + ? { + items: nextPage.nextPage.items, + hasNext: nextPage.nextPage.hasNext, + } + : undefined, + ); + }, + }; +} + +describe("collectPaginatedResults", () => { + it("should return all items when single page fits within limit", async () => { + const page = createMockPage([1, 2, 3]); + const result = await collectPaginatedResults(page, 10); + + expect(result.items).toEqual([1, 2, 3]); + expect(result.hasMore).toBe(false); + expect(result.pagesConsumed).toBe(1); + }); + + it("should collect multiple pages to fill limit", async () => { + const page = createMockPage([1, 2], { + items: [3, 4], + hasNext: false, + }); + const result = await collectPaginatedResults(page, 10); + + expect(result.items).toEqual([1, 2, 3, 4]); + expect(result.hasMore).toBe(false); + expect(result.pagesConsumed).toBe(2); + }); + + it("should stop at limit and report hasMore: true", async () => { + const page = createMockPage([1, 2, 3], { + items: [4, 5, 6], + hasNext: true, + nextPage: { items: [7, 8, 9], hasNext: false }, + }); + const result = await collectPaginatedResults(page, 5); + + expect(result.items).toEqual([1, 2, 3, 4, 5]); + expect(result.hasMore).toBe(true); + expect(result.pagesConsumed).toBe(2); + }); + + it("should handle empty first page", async () => { + const page = createMockPage([]); + const result = await collectPaginatedResults(page, 10); + + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.pagesConsumed).toBe(1); + }); + + it("should handle next() returning null early", async () => { + const page = { + items: [1, 2], + hasNext: () => true, + next: async () => null, + }; + const result = await collectPaginatedResults(page, 10); + + expect(result.items).toEqual([1, 2]); + expect(result.hasMore).toBe(false); + expect(result.pagesConsumed).toBe(1); + }); + + it("should propagate errors from next()", async () => { + const page = { + items: [1, 2], + hasNext: () => true, + next: async () => { + throw new Error("Network error"); + }, + }; + + await expect(collectPaginatedResults(page, 10)).rejects.toThrow( + "Network error", + ); + }); + + it("should report hasMore false when items exactly equal limit with no more pages", async () => { + const page = createMockPage([1, 2, 3]); + // hasNext returns false, so even though items.length === limit, hasMore is false + const result = await collectPaginatedResults(page, 3); + + expect(result.items).toEqual([1, 2, 3]); + expect(result.hasMore).toBe(false); + expect(result.pagesConsumed).toBe(1); + }); + + it("should throw when limit is zero", async () => { + const page = createMockPage([1, 2, 3]); + await expect(collectPaginatedResults(page, 0)).rejects.toThrow( + "Pagination limit must be a positive integer.", + ); + }); + + it("should throw when limit is negative", async () => { + const page = createMockPage([1, 2, 3]); + await expect(collectPaginatedResults(page, -1)).rejects.toThrow( + "Pagination limit must be a positive integer.", + ); + }); + + it("should throw when limit is not an integer", async () => { + const page = createMockPage([1, 2, 3]); + await expect(collectPaginatedResults(page, 1.5)).rejects.toThrow( + "Pagination limit must be a positive integer.", + ); + }); + + it("should report hasMore when items exactly equal limit and page has next", async () => { + const page = { + items: [1, 2, 3], + hasNext: () => true, + next: async () => createMockPage([4, 5]), + }; + const result = await collectPaginatedResults(page, 3); + + expect(result.items).toEqual([1, 2, 3]); + expect(result.hasMore).toBe(true); + expect(result.pagesConsumed).toBe(1); + }); +}); + +describe("collectFilteredPaginatedResults", () => { + it("should filter items across pages", async () => { + const page = createMockPage([1, 2, 3, 4, 5], { + items: [6, 7, 8, 9, 10], + hasNext: false, + }); + const result = await collectFilteredPaginatedResults( + page, + 3, + (n) => n % 2 === 0, + ); + + expect(result.items).toEqual([2, 4, 6]); + expect(result.hasMore).toBe(true); + expect(result.pagesConsumed).toBe(2); + }); + + it("should respect maxPages safety cap", async () => { + // Create a chain of pages that never ends effectively + let callCount = 0; + const createInfinitePage = (): ReturnType< + typeof createMockPage + > => ({ + items: [callCount++], + hasNext: () => true, + next: async () => createInfinitePage(), + }); + + const result = await collectFilteredPaginatedResults( + createInfinitePage(), + 1000, + () => true, + 3, + ); + + expect(result.pagesConsumed).toBe(3); + expect(result.hasMore).toBe(true); + }); + + it("should throw when limit is zero", async () => { + const page = createMockPage([1, 2, 3]); + await expect( + collectFilteredPaginatedResults(page, 0, () => true), + ).rejects.toThrow("Pagination limit must be a positive integer."); + }); + + it("should report hasMore false when maxPages reached but no more pages exist", async () => { + // Only 2 pages exist, maxPages is 2 — should NOT report hasMore + const page = createMockPage([1, 2], { + items: [3, 4], + hasNext: false, + }); + const result = await collectFilteredPaginatedResults( + page, + 100, + () => true, + 2, + ); + + expect(result.items).toEqual([1, 2, 3, 4]); + expect(result.hasMore).toBe(false); + expect(result.pagesConsumed).toBe(2); + }); + + it("should return empty when no items match filter", async () => { + const page = createMockPage([1, 3, 5]); + const result = await collectFilteredPaginatedResults( + page, + 10, + (n) => n % 2 === 0, + ); + + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.pagesConsumed).toBe(1); + }); +});