diff --git a/README.md b/README.md index 8e2ec25a..9c27fc82 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,11 @@ $ ably-interactive * [`ably bench publisher CHANNEL`](#ably-bench-publisher-channel) * [`ably bench subscriber CHANNEL`](#ably-bench-subscriber-channel) * [`ably channels`](#ably-channels) +* [`ably channels annotations`](#ably-channels-annotations) +* [`ably channels annotations delete CHANNEL SERIAL TYPE`](#ably-channels-annotations-delete-channel-serial-type) +* [`ably channels annotations get CHANNEL SERIAL`](#ably-channels-annotations-get-channel-serial) +* [`ably channels annotations publish CHANNEL SERIAL TYPE`](#ably-channels-annotations-publish-channel-serial-type) +* [`ably channels annotations subscribe CHANNEL`](#ably-channels-annotations-subscribe-channel) * [`ably channels append CHANNEL SERIAL MESSAGE`](#ably-channels-append-channel-serial-message) * [`ably channels batch-publish [MESSAGE]`](#ably-channels-batch-publish-message) * [`ably channels delete CHANNEL SERIAL`](#ably-channels-delete-channel-serial) @@ -1373,6 +1378,7 @@ EXAMPLES $ ably channels list COMMANDS + ably channels annotations Manage annotations on Ably Pub/Sub channel messages ably channels append Append data to a message on an Ably channel ably channels batch-publish Publish messages to multiple Ably channels with a single request ably channels delete Delete a message on an Ably channel @@ -1388,6 +1394,179 @@ COMMANDS _See code: [src/commands/channels/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/index.ts)_ +## `ably channels annotations` + +Manage annotations on Ably Pub/Sub channel messages + +``` +USAGE + $ ably channels annotations + +DESCRIPTION + Manage annotations on Ably Pub/Sub channel messages + +EXAMPLES + $ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --name thumbsup + + $ ably channels annotations subscribe my-channel + + $ ably channels annotations get my-channel "01234567890:0" +``` + +_See code: [src/commands/channels/annotations/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/annotations/index.ts)_ + +## `ably channels annotations delete CHANNEL SERIAL TYPE` + +Delete an annotation from a channel message + +``` +USAGE + $ ably channels annotations delete CHANNEL SERIAL TYPE [-v] [--json | --pretty-json] [--client-id ] [--count ] + [-n ] + +ARGUMENTS + CHANNEL The channel name + SERIAL The serial of the message to remove annotation from + TYPE The annotation type (e.g., reactions:flag.v1, reactions:multiple.v1) + +FLAGS + -n, --name= The annotation name (e.g., emoji name for reactions) + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --count= The annotation count (for multiple.v1 types) + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Delete an annotation from a channel message + +EXAMPLES + $ ably channels annotations delete my-channel "01234567890:0" "reactions:flag.v1" --name thumbsup + + $ ably channels annotations delete my-channel "01234567890:0" "reactions:multiple.v1" --name thumbsup --count 2 + + $ ably channels annotations delete my-channel "01234567890:0" "reactions:flag.v1" --json + + $ ably channels annotations delete my-channel "01234567890:0" "reactions:flag.v1" --pretty-json +``` + +_See code: [src/commands/channels/annotations/delete.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/annotations/delete.ts)_ + +## `ably channels annotations get CHANNEL SERIAL` + +Get annotations for a channel message + +``` +USAGE + $ ably channels annotations get CHANNEL SERIAL [-v] [--json | --pretty-json] [--limit ] + +ARGUMENTS + CHANNEL The channel name + SERIAL The serial of the message to get annotations for + +FLAGS + -v, --verbose Output verbose logs + --json Output in JSON format + --limit= [default: 50] Maximum number of results to return (default: 50) + --pretty-json Output in colorized JSON format + +DESCRIPTION + Get annotations for a channel message + +EXAMPLES + $ ably channels annotations get my-channel "01234567890:0" + + $ ably channels annotations get my-channel "01234567890:0" --limit 100 + + $ ably channels annotations get my-channel "01234567890:0" --json + + $ ably channels annotations get my-channel "01234567890:0" --pretty-json +``` + +_See code: [src/commands/channels/annotations/get.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/annotations/get.ts)_ + +## `ably channels annotations publish CHANNEL SERIAL TYPE` + +Publish an annotation on a channel message + +``` +USAGE + $ ably channels annotations publish CHANNEL SERIAL TYPE [-v] [--json | --pretty-json] [--client-id ] [--count ] + [--data ] [-e ] [-n ] + +ARGUMENTS + CHANNEL The channel name + SERIAL The serial of the message to annotate + TYPE The annotation type (e.g., reactions:flag.v1, reactions:multiple.v1) + +FLAGS + -e, --encoding= The encoding for the annotation data + -n, --name= The annotation name (e.g., emoji name for reactions) + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --count= The annotation count (for multiple.v1 types) + --data= Arbitrary annotation payload (JSON string or plain text) + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Publish an annotation on a channel message + +EXAMPLES + $ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --name thumbsup + + $ ably channels annotations publish my-channel "01234567890:0" "reactions:multiple.v1" --name thumbsup --count 3 + + $ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --data '{"key":"value"}' + + $ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --json + + $ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --pretty-json +``` + +_See code: [src/commands/channels/annotations/publish.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/annotations/publish.ts)_ + +## `ably channels annotations subscribe CHANNEL` + +Subscribe to annotations on an Ably channel + +``` +USAGE + $ ably channels annotations subscribe CHANNEL [-v] [--json | --pretty-json] [--client-id ] [-D ] [--rewind ] + [--type ] + +ARGUMENTS + CHANNEL The channel name to subscribe to annotations on + +FLAGS + -D, --duration= Automatically exit after N seconds + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --json Output in JSON format + --pretty-json Output in colorized JSON format + --rewind= Number of messages to rewind when subscribing (default: 0) + --type= Filter annotations by type + +DESCRIPTION + Subscribe to annotations on an Ably channel + +EXAMPLES + $ ably channels annotations subscribe my-channel + + $ ably channels annotations subscribe my-channel --type "reactions:flag.v1" + + $ ably channels annotations subscribe my-channel --json + + $ ably channels annotations subscribe my-channel --pretty-json + + $ ably channels annotations subscribe my-channel --duration 30 +``` + +_See code: [src/commands/channels/annotations/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/annotations/subscribe.ts)_ + ## `ably channels append CHANNEL SERIAL MESSAGE` Append data to a message on an Ably channel diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index 564463e4..daa9b861 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -43,7 +43,7 @@ This document outlines the directory structure of the Ably CLI project. │ │ ├── auth/ # Authentication (keys, tokens) │ │ ├── bench/ # Benchmarking (publisher, subscriber) │ │ ├── channel-rule/ # Hidden alias → apps/rules/ -│ │ ├── channels/ # Pub/Sub channels (publish, subscribe, presence, history, etc.) +│ │ ├── channels/ # Pub/Sub channels (publish, subscribe, presence, history, annotations, etc.) │ │ ├── config/ # CLI config management (show, path) │ │ ├── connections/ # Client connections (test) │ │ ├── integrations/ # Integration rules diff --git a/src/commands/channels/annotations/delete.ts b/src/commands/channels/annotations/delete.ts new file mode 100644 index 00000000..bb21a459 --- /dev/null +++ b/src/commands/channels/annotations/delete.ts @@ -0,0 +1,127 @@ +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; + +import { AblyBaseCommand } from "../../../base-command.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; +import { + extractSummarizationType, + validateAnnotationParams, +} from "../../../utils/annotations.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../../utils/output.js"; + +export default class ChannelsAnnotationsDelete extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The channel name", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to remove annotation from", + required: true, + }), + type: Args.string({ + description: + "The annotation type (e.g., reactions:flag.v1, reactions:multiple.v1)", + required: true, + }), + }; + + static override description = "Delete an annotation from a channel message"; + + static override examples = [ + '$ ably channels annotations delete my-channel "01234567890:0" "reactions:flag.v1" --name thumbsup', + '$ ably channels annotations delete my-channel "01234567890:0" "reactions:multiple.v1" --name thumbsup', + '$ ably channels annotations delete my-channel "01234567890:0" "reactions:flag.v1" --json', + '$ ably channels annotations delete my-channel "01234567890:0" "reactions:flag.v1" --pretty-json', + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + name: Flags.string({ + char: "n", + description: "The annotation name (e.g., emoji name for reactions)", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsAnnotationsDelete); + const channelName = args.channel; + const serial = args.serial; + const type = args.type; + + let client: Ably.Realtime | null = null; + + try { + const summarization = extractSummarizationType(type); + const validationErrors = validateAnnotationParams(summarization, { + name: flags.name, + }); + if (validationErrors.length > 0) { + this.fail(validationErrors.join("\n"), flags, "annotationDelete"); + } + + // Uses Realtime client because RestAnnotations.delete is not exposed in SDK types + client = await this.createAblyRealtimeClient(flags); + if (!client) return; + + const channel = client.channels.get(channelName); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Deleting annotation on message ${formatResource(serial)} in channel ${formatResource(channelName)}`, + ), + ); + } + + const annotation: Ably.OutboundAnnotation = { type }; + if (flags.name !== undefined) annotation.name = flags.name; + + await channel.annotations.delete(serial, annotation); + + this.logCliEvent( + flags, + "annotationDelete", + "annotationDeleted", + `Deleted annotation on message ${serial} in channel ${channelName}`, + { + channel: channelName, + serial, + type, + name: flags.name, + }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + channel: channelName, + serial, + type, + name: flags.name, + }, + flags, + ); + } else { + this.log( + formatSuccess( + `Annotation deleted on message ${formatResource(serial)} in channel ${formatResource(channelName)}.`, + ), + ); + } + } catch (error) { + this.fail(error, flags, "annotationDelete", { + channel: channelName, + serial, + type, + }); + } finally { + client?.close(); + } + } +} diff --git a/src/commands/channels/annotations/get.ts b/src/commands/channels/annotations/get.ts new file mode 100644 index 00000000..d50215ca --- /dev/null +++ b/src/commands/channels/annotations/get.ts @@ -0,0 +1,133 @@ +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; + +import { AblyBaseCommand } from "../../../base-command.js"; +import { productApiFlags } from "../../../flags.js"; +import { + formatAnnotationsOutput, + formatCountLabel, + formatIndex, + formatLimitWarning, + formatMessageTimestamp, + formatProgress, + formatResource, + formatTimestamp, +} from "../../../utils/output.js"; +import type { AnnotationDisplayFields } from "../../../utils/output.js"; + +export default class ChannelsAnnotationsGet extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The channel name", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to get annotations for", + required: true, + }), + }; + + static override description = "Get annotations for a channel message"; + + static override examples = [ + '$ ably channels annotations get my-channel "01234567890:0"', + '$ ably channels annotations get my-channel "01234567890:0" --limit 100', + '$ ably channels annotations get my-channel "01234567890:0" --json', + '$ ably channels annotations get my-channel "01234567890:0" --pretty-json', + ]; + + static override flags = { + ...productApiFlags, + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return (default: 100)", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsAnnotationsGet); + const channelName = args.channel; + const serial = args.serial; + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const channel = rest.channels.get(channelName); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Getting annotations for message ${formatResource(serial)} in channel ${formatResource(channelName)}`, + ), + ); + } + + const params: Ably.GetAnnotationsParams = { limit: flags.limit }; + + const result = await channel.annotations.get(serial, params); + const annotations = result.items || []; + + this.logCliEvent( + flags, + "annotationGet", + "annotationsFetched", + `Fetched annotations for message ${serial} in channel ${channelName}`, + { channel: channelName, serial, count: annotations.length }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { channel: channelName, serial, annotations }, + flags, + ); + } else { + if (annotations.length === 0) { + this.log("No annotations found for this message."); + return; + } + + this.log( + `Found ${formatCountLabel(annotations.length, "annotation")} for message ${formatResource(serial)} in channel ${formatResource(channelName)}`, + ); + this.log(""); + + const displayAnnotations: AnnotationDisplayFields[] = annotations.map( + (annotation, index) => { + const ts = annotation.timestamp ?? Date.now(); + return { + id: annotation.id, + timestamp: ts, + channel: channelName, + type: annotation.type, + action: + annotation.action === undefined + ? undefined + : String(annotation.action), + name: annotation.name, + clientId: annotation.clientId, + count: annotation.count, + serial: annotation.serial, + data: annotation.data, + indexPrefix: `${formatIndex(index + 1)} ${formatTimestamp(formatMessageTimestamp(ts))}`, + }; + }, + ); + + this.log(formatAnnotationsOutput(displayAnnotations)); + + const warning = formatLimitWarning( + annotations.length, + flags.limit, + "annotations", + ); + if (warning) this.log(warning); + } + } catch (error) { + this.fail(error, flags, "annotationGet", { + channel: channelName, + serial, + }); + } + } +} diff --git a/src/commands/channels/annotations/index.ts b/src/commands/channels/annotations/index.ts new file mode 100644 index 00000000..c7c0e965 --- /dev/null +++ b/src/commands/channels/annotations/index.ts @@ -0,0 +1,15 @@ +import { BaseTopicCommand } from "../../../base-topic-command.js"; + +export default class ChannelsAnnotations extends BaseTopicCommand { + protected topicName = "channels:annotations"; + protected commandGroup = "Pub/Sub channel annotations"; + + static override description = + "Manage annotations on Ably Pub/Sub channel messages"; + + static override examples = [ + '<%= config.bin %> <%= command.id %> publish my-channel "01234567890:0" "reactions:flag.v1" --name thumbsup', + "<%= config.bin %> <%= command.id %> subscribe my-channel", + '<%= config.bin %> <%= command.id %> get my-channel "01234567890:0"', + ]; +} diff --git a/src/commands/channels/annotations/publish.ts b/src/commands/channels/annotations/publish.ts new file mode 100644 index 00000000..584a2a3a --- /dev/null +++ b/src/commands/channels/annotations/publish.ts @@ -0,0 +1,133 @@ +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; + +import { AblyBaseCommand } from "../../../base-command.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; +import { + extractSummarizationType, + validateAnnotationParams, +} from "../../../utils/annotations.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../../utils/output.js"; + +export default class ChannelsAnnotationsPublish extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The channel name", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to annotate", + required: true, + }), + type: Args.string({ + description: + "The annotation type (e.g., reactions:flag.v1, reactions:multiple.v1)", + required: true, + }), + }; + + static override description = "Publish an annotation on a channel message"; + + static override examples = [ + '$ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --name thumbsup', + '$ ably channels annotations publish my-channel "01234567890:0" "reactions:multiple.v1" --name thumbsup --count 3', + '$ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --data \'{"key":"value"}\'', + '$ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --json', + '$ ably channels annotations publish my-channel "01234567890:0" "reactions:flag.v1" --pretty-json', + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + count: Flags.integer({ + description: "The annotation count (for multiple.v1 types)", + }), + data: Flags.string({ + description: "Arbitrary annotation payload (JSON string or plain text)", + }), + encoding: Flags.string({ + char: "e", + description: "The encoding for the annotation data", + }), + name: Flags.string({ + char: "n", + description: "The annotation name (e.g., emoji name for reactions)", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsAnnotationsPublish); + const channelName = args.channel; + const serial = args.serial; + const type = args.type; + + try { + const summarization = extractSummarizationType(type); + const validationErrors = validateAnnotationParams(summarization, { + name: flags.name, + }); + if (validationErrors.length > 0) { + this.fail(validationErrors.join("\n"), flags, "annotationPublish"); + } + + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const channel = rest.channels.get(channelName); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Publishing annotation on message ${formatResource(serial)} in channel ${formatResource(channelName)}`, + ), + ); + } + + const annotation: Ably.OutboundAnnotation = { type }; + if (flags.name !== undefined) annotation.name = flags.name; + if (flags.count !== undefined) annotation.count = flags.count; + if (flags.data !== undefined) { + try { + annotation.data = JSON.parse(flags.data); + } catch { + annotation.data = flags.data; + } + } + + if (flags.encoding !== undefined) annotation.encoding = flags.encoding; + + await channel.annotations.publish(serial, annotation); + + this.logCliEvent( + flags, + "annotationPublish", + "annotationPublished", + `Published annotation on message ${serial} in channel ${channelName}`, + { channel: channelName, serial, type, name: flags.name }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { channel: channelName, serial, type, name: flags.name }, + flags, + ); + } else { + this.log( + formatSuccess( + `Annotation published on message ${formatResource(serial)} in channel ${formatResource(channelName)}.`, + ), + ); + } + } catch (error) { + this.fail(error, flags, "annotationPublish", { + channel: channelName, + serial, + type, + }); + } + } +} diff --git a/src/commands/channels/annotations/subscribe.ts b/src/commands/channels/annotations/subscribe.ts new file mode 100644 index 00000000..e58457a4 --- /dev/null +++ b/src/commands/channels/annotations/subscribe.ts @@ -0,0 +1,194 @@ +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; + +import { AblyBaseCommand } from "../../../base-command.js"; +import { + clientIdFlag, + durationFlag, + productApiFlags, + rewindFlag, +} from "../../../flags.js"; +import { + formatAnnotationsOutput, + formatListening, + formatMessageTimestamp, + formatProgress, + formatResource, + formatSuccess, +} from "../../../utils/output.js"; +import type { AnnotationDisplayFields } from "../../../utils/output.js"; + +export default class ChannelsAnnotationsSubscribe extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The channel name to subscribe to annotations on", + required: true, + }), + }; + + static override description = "Subscribe to annotations on an Ably channel"; + + static override examples = [ + "$ ably channels annotations subscribe my-channel", + '$ ably channels annotations subscribe my-channel --type "reactions:flag.v1"', + "$ ably channels annotations subscribe my-channel --json", + "$ ably channels annotations subscribe my-channel --pretty-json", + "$ ably channels annotations subscribe my-channel --duration 30", + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + ...durationFlag, + ...rewindFlag, + type: Flags.string({ + description: "Filter annotations by type", + }), + }; + + private client: Ably.Realtime | null = null; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsAnnotationsSubscribe); + const channelName = args.channel; + + try { + this.client = await this.createAblyRealtimeClient(flags); + if (!this.client) return; + + const client = this.client; + + const channelOptions: Ably.ChannelOptions = { + modes: ["ANNOTATION_SUBSCRIBE"], + }; + + this.configureRewind( + channelOptions, + flags.rewind, + flags, + "annotationSubscribe", + channelName, + ); + + const channel = client.channels.get(channelName, channelOptions); + + this.setupConnectionStateLogging(client, flags, { + includeUserFriendlyMessages: true, + }); + + this.logCliEvent( + flags, + "annotationSubscribe", + "subscribing", + `Subscribing to annotations on channel: ${channelName}`, + { channel: channelName }, + ); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Attaching to channel: ${formatResource(channelName)}`, + ), + ); + } + + this.setupChannelStateLogging(channel, flags, { + includeUserFriendlyMessages: true, + }); + + const attachPromise = new Promise((resolve) => { + const checkAttached = () => { + if (channel.state === "attached") { + resolve(); + } + }; + channel.once("attached", checkAttached); + checkAttached(); + }); + + const callback = (annotation: Ably.Annotation) => { + const timestamp = + annotation.timestamp !== undefined && annotation.timestamp !== null + ? formatMessageTimestamp(annotation.timestamp) + : "[Unknown timestamp]"; + const annotationData = { + id: annotation.id, + timestamp, + channel: channelName, + annotationType: annotation.type, + action: annotation.action, + name: annotation.name, + clientId: annotation.clientId, + count: annotation.count, + serial: annotation.serial, + data: annotation.data, + }; + + this.logCliEvent( + flags, + "annotationSubscribe", + "annotationReceived", + `Received annotation on channel ${channelName}`, + annotationData, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonEvent({ annotation: annotationData }, flags); + } else { + const displayFields: AnnotationDisplayFields = { + id: annotation.id, + timestamp: annotation.timestamp ?? Date.now(), + channel: channelName, + type: annotation.type, + action: + annotation.action === undefined + ? undefined + : String(annotation.action), + name: annotation.name, + clientId: annotation.clientId, + count: annotation.count, + serial: annotation.serial, + data: annotation.data, + }; + this.log(formatAnnotationsOutput([displayFields])); + this.log(""); + } + }; + + if (flags.type) { + await channel.annotations.subscribe(flags.type, callback); + } else { + await channel.annotations.subscribe(callback); + } + + await attachPromise; + + if (!this.shouldOutputJson(flags)) { + this.log( + formatSuccess( + `Subscribed to annotations on channel: ${formatResource(channelName)}.`, + ), + ); + this.log(formatListening("Listening for annotations.")); + this.log(""); + } + + this.logCliEvent( + flags, + "annotationSubscribe", + "listening", + "Listening for annotations. Press Ctrl+C to exit.", + ); + + await this.waitAndTrackCleanup( + flags, + "annotationSubscribe", + flags.duration, + ); + } catch (error) { + this.fail(error, flags, "annotationSubscribe", { + channel: channelName, + }); + } + } +} diff --git a/src/commands/channels/append.ts b/src/commands/channels/append.ts index 4fd19175..cc07b10c 100644 --- a/src/commands/channels/append.ts +++ b/src/commands/channels/append.ts @@ -3,7 +3,6 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; -import { BaseFlags } from "../../types/cli.js"; import { prepareMessageFromInput } from "../../utils/message.js"; import { formatProgress, @@ -61,7 +60,7 @@ export default class ChannelsAppend extends AblyBaseCommand { const serial = args.serial; try { - const rest = await this.createAblyRestClient(flags as BaseFlags); + const rest = await this.createAblyRestClient(flags); if (!rest) return; const channel = rest.channels.get(channelName); @@ -111,7 +110,7 @@ export default class ChannelsAppend extends AblyBaseCommand { } } } catch (error) { - this.fail(error, flags as BaseFlags, "channelAppend", { + this.fail(error, flags, "channelAppend", { channel: channelName, serial, }); diff --git a/src/commands/channels/delete.ts b/src/commands/channels/delete.ts index 4d20ff42..555309d3 100644 --- a/src/commands/channels/delete.ts +++ b/src/commands/channels/delete.ts @@ -3,7 +3,6 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; -import { BaseFlags } from "../../types/cli.js"; import { formatProgress, formatResource, @@ -46,7 +45,7 @@ export default class ChannelsDelete extends AblyBaseCommand { const serial = args.serial; try { - const rest = await this.createAblyRestClient(flags as BaseFlags); + const rest = await this.createAblyRestClient(flags); if (!rest) return; const channel = rest.channels.get(channelName); @@ -99,7 +98,7 @@ export default class ChannelsDelete extends AblyBaseCommand { } } } catch (error) { - this.fail(error, flags as BaseFlags, "channelDelete", { + this.fail(error, flags, "channelDelete", { channel: channelName, serial, }); diff --git a/src/commands/channels/update.ts b/src/commands/channels/update.ts index 9079c9f1..41cac575 100644 --- a/src/commands/channels/update.ts +++ b/src/commands/channels/update.ts @@ -3,7 +3,6 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; -import { BaseFlags } from "../../types/cli.js"; import { prepareMessageFromInput } from "../../utils/message.js"; import { formatProgress, @@ -61,7 +60,7 @@ export default class ChannelsUpdate extends AblyBaseCommand { const serial = args.serial; try { - const rest = await this.createAblyRestClient(flags as BaseFlags); + const rest = await this.createAblyRestClient(flags); if (!rest) return; const channel = rest.channels.get(channelName); @@ -111,7 +110,7 @@ export default class ChannelsUpdate extends AblyBaseCommand { } } } catch (error) { - this.fail(error, flags as BaseFlags, "channelUpdate", { + this.fail(error, flags, "channelUpdate", { channel: channelName, serial, }); diff --git a/src/utils/annotations.ts b/src/utils/annotations.ts new file mode 100644 index 00000000..a6dcd102 --- /dev/null +++ b/src/utils/annotations.ts @@ -0,0 +1,54 @@ +/** + * Extract the summarization method from an annotation type string. + * Format: "namespace:summarization.version" -> returns "summarization" + * + * The Ably SDK does not validate annotation type format client-side. + * This provides early CLI-level feedback for obvious format errors. + */ +export function extractSummarizationType(annotationType: string): string { + const colonIndex = annotationType.indexOf(":"); + if (colonIndex === -1) { + throw new Error( + 'Invalid annotation type format. Expected "namespace:summarization.version" (e.g., "reactions:flag.v1")', + ); + } + const summarizationPart = annotationType.slice(colonIndex + 1); + const dotIndex = summarizationPart.indexOf("."); + if (dotIndex === -1) { + throw new Error( + 'Invalid annotation type format. Expected "namespace:summarization.version" (e.g., "reactions:flag.v1")', + ); + } + return summarizationPart.slice(0, dotIndex); +} + +/** + * Summarization types that require a `name` parameter. + * Per Ably docs: "In the case of the distinct, unique, or multiple + * aggregation types, you should also specify a name." + */ +const NAME_REQUIRED_TYPES = new Set(["distinct", "unique", "multiple"]); + +/** + * Validate required parameters for the given summarization type. + * Unknown types pass through without validation (forward compatibility). + * + * NOTE: `count` is NOT validated as required for any type. + * Per Ably docs, count on multiple.v1 "defaults to incrementing by 1" + * if not specified. It is always optional. + * + * The Ably SDK performs no client-side field validation — all requirements + * are enforced server-side. This provides early, human-readable errors. + */ +export function validateAnnotationParams( + summarization: string, + options: { name?: string }, +): string[] { + const errors: string[] = []; + + if (NAME_REQUIRED_TYPES.has(summarization) && !options.name) { + errors.push(`--name is required for "${summarization}" annotation types`); + } + + return errors; +} diff --git a/src/utils/output.ts b/src/utils/output.ts index d79a6928..870f6093 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -266,6 +266,82 @@ export function formatPresenceOutput( return blocks.join("\n\n"); } +export interface AnnotationDisplayFields { + id?: string; + timestamp: number; + channel: string; + type: string; + action?: string; + name?: string; + clientId?: string; + count?: number; + serial?: string; + data?: unknown; + indexPrefix?: string; +} + +export function formatAnnotationsOutput( + annotations: AnnotationDisplayFields[], +): string { + if (annotations.length === 0) { + return "No annotations found."; + } + + const blocks: string[] = []; + + for (const ann of annotations) { + const lines: string[] = []; + + if (ann.indexPrefix) { + lines.push(ann.indexPrefix); + } else { + lines.push(formatTimestamp(new Date(ann.timestamp).toISOString())); + } + + if (ann.id) { + lines.push(`${formatLabel("ID")} ${ann.id}`); + } + + lines.push( + `${formatLabel("Timestamp")} ${formatMessageTimestamp(ann.timestamp)}`, + `${formatLabel("Channel")} ${formatResource(ann.channel)}`, + `${formatLabel("Type")} ${formatEventType(ann.type || "(none)")}`, + ); + + if (ann.action) { + lines.push(`${formatLabel("Action")} ${formatEventType(ann.action)}`); + } + + if (ann.name) { + lines.push(`${formatLabel("Name")} ${ann.name}`); + } + + if (ann.clientId) { + lines.push(`${formatLabel("Client ID")} ${formatClientId(ann.clientId)}`); + } + + if (ann.count !== undefined) { + lines.push(`${formatLabel("Count")} ${ann.count}`); + } + + if (ann.serial) { + lines.push(`${formatLabel("Serial")} ${ann.serial}`); + } + + if (ann.data !== undefined) { + if (isJsonData(ann.data)) { + lines.push(`${formatLabel("Data")}`, formatMessageData(ann.data)); + } else { + lines.push(`${formatLabel("Data")} ${String(ann.data)}`); + } + } + + blocks.push(lines.join("\n")); + } + + return blocks.join("\n\n"); +} + export type JsonRecordType = "error" | "event" | "log" | "result"; /** diff --git a/test/helpers/mock-ably-realtime.ts b/test/helpers/mock-ably-realtime.ts index 1eee489c..b4af9110 100644 --- a/test/helpers/mock-ably-realtime.ts +++ b/test/helpers/mock-ably-realtime.ts @@ -30,6 +30,17 @@ import { EventEmitter, type AblyEventEmitter } from "./ably-event-emitter.js"; /** * Mock channel type with all common methods. */ +export interface MockRealtimeAnnotations { + subscribe: Mock; + unsubscribe: Mock; + publish: Mock; + delete: Mock; + // Internal emitter for simulating events + _emitter: AblyEventEmitter; + // Helper to emit annotation events + _emit: (annotation: unknown) => void; +} + export interface MockRealtimeChannel { name: string; state: string; @@ -44,6 +55,7 @@ export interface MockRealtimeChannel { once: Mock; setOptions: Mock; presence: MockPresence; + annotations: MockRealtimeAnnotations; // Internal emitter for simulating events _emitter: AblyEventEmitter; // Helper to emit message events @@ -161,6 +173,45 @@ function createMockPresence(): MockPresence { return presence; } +/** + * Create a mock annotations object for a realtime channel. + */ +function createMockAnnotations( + onSubscribe: () => Promise | void, +): MockRealtimeAnnotations { + const emitter = new EventEmitter(); + + const annotations: MockRealtimeAnnotations = { + subscribe: vi.fn(async (typeOrCallback: unknown, callback?: unknown) => { + const cb = (callback ?? typeOrCallback) as (...args: unknown[]) => void; + const type = callback ? (typeOrCallback as string) : null; + emitter.on(type, cb); + await onSubscribe(); + }), + unsubscribe: vi.fn((typeOrCallback?: unknown, callback?: unknown) => { + if (!typeOrCallback) { + emitter.off(); + } else if (typeof typeOrCallback === "function") { + emitter.off(null, typeOrCallback as (...args: unknown[]) => void); + } else if (callback) { + emitter.off( + typeOrCallback as string, + callback as (...args: unknown[]) => void, + ); + } + }), + publish: vi.fn().mockResolvedValue(), + delete: vi.fn().mockResolvedValue(), + _emitter: emitter, + _emit: (annotation: unknown) => { + const ann = annotation as Record; + emitter.emit((ann.type as string) || "", annotation); + }, + }; + + return annotations; +} + /** * Create a mock channel object. * @@ -242,6 +293,7 @@ function createMockChannel(name: string): MockRealtimeChannel { }), setOptions: vi.fn().mockImplementation(async () => {}), presence: createMockPresence(), + annotations: createMockAnnotations(() => channel.attach()), _emitter: emitter, _emit: (message: Message) => { emitter.emit(message.name || "", message); diff --git a/test/helpers/mock-ably-rest.ts b/test/helpers/mock-ably-rest.ts index 109b81f8..5afd487f 100644 --- a/test/helpers/mock-ably-rest.ts +++ b/test/helpers/mock-ably-rest.ts @@ -23,6 +23,12 @@ import { vi, type Mock } from "vitest"; /** * Mock REST channel type with all common methods. */ +export interface MockRestAnnotations { + publish: Mock; + delete: Mock; + get: Mock; +} + export interface MockRestChannel { name: string; publish: Mock; @@ -32,6 +38,7 @@ export interface MockRestChannel { deleteMessage: Mock; appendMessage: Mock; presence: MockRestPresence; + annotations: MockRestAnnotations; } /** @@ -113,6 +120,17 @@ function createMockRestPresence(): MockRestPresence { }; } +/** + * Create a mock REST annotations object. + */ +function createMockRestAnnotations(): MockRestAnnotations { + return { + publish: vi.fn().mockResolvedValue(), + delete: vi.fn().mockResolvedValue(), + get: vi.fn().mockResolvedValue({ items: [] }), + }; +} + /** * Create a mock REST channel object. */ @@ -147,6 +165,7 @@ function createMockRestChannel(name: string): MockRestChannel { }, }), presence: createMockRestPresence(), + annotations: createMockRestAnnotations(), }; } diff --git a/test/unit/commands/channels/annotations/delete.test.ts b/test/unit/commands/channels/annotations/delete.test.ts new file mode 100644 index 00000000..cbb70862 --- /dev/null +++ b/test/unit/commands/channels/annotations/delete.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { captureJsonLogs } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("channels:annotations:delete command", () => { + beforeEach(() => { + const mock = getMockAblyRealtime(); + + // Configure connection.once to immediately call callback for 'connected' + mock.connection.once.mockImplementation( + (event: string, callback: () => void) => { + if (event === "connected") { + callback(); + } + }, + ); + }); + + standardHelpTests("channels:annotations:delete", import.meta.url); + standardArgValidationTests("channels:annotations:delete", import.meta.url, { + requiredArgs: ["test-channel", "serial-001", "reactions:flag.v1"], + }); + standardFlagTests("channels:annotations:delete", import.meta.url, [ + "--json", + "--name", + ]); + + describe("functionality", () => { + it("should delete an annotation successfully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "reactions:flag.v1", + ], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("test-channel"); + expect(channel.annotations.delete).toHaveBeenCalledExactlyOnceWith( + "serial-001", + { + type: "reactions:flag.v1", + }, + ); + expect(stdout).toContain("Annotation deleted"); + }); + + it("should pass --name flag to annotation", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "reactions:flag.v1", + "--name", + "thumbsup", + ], + import.meta.url, + ); + + expect(channel.annotations.delete).toHaveBeenCalledWith("serial-001", { + type: "reactions:flag.v1", + name: "thumbsup", + }); + }); + + it("should succeed with multiple.v1 delete + --name (no count needed)", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "reactions:multiple.v1", + "--name", + "thumbsup", + ], + import.meta.url, + ); + + expect(channel.annotations.delete).toHaveBeenCalledWith("serial-001", { + type: "reactions:multiple.v1", + name: "thumbsup", + }); + expect(stdout).toContain("Annotation deleted"); + }); + + it("should output JSON when --json flag is used", async () => { + const records = await captureJsonLogs(async () => { + await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "reactions:flag.v1", + "--json", + ], + import.meta.url, + ); + }); + + expect(records.length).toBeGreaterThanOrEqual(1); + const result = records[0]; + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "channels:annotations:delete"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("channel", "test-channel"); + expect(result).toHaveProperty("serial", "serial-001"); + }); + }); + + describe("error handling", () => { + it("should error when --name is missing for distinct.v1 type", async () => { + const { error } = await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "reactions:distinct.v1", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("--name is required"); + }); + + it("should error when --name is missing for unique.v1 type", async () => { + const { error } = await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "reactions:unique.v1", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("--name is required"); + }); + + it("should error on invalid annotation type format", async () => { + const { error } = await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "invalidformat", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Invalid annotation type format"); + }); + + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + channel.annotations.delete.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + [ + "channels:annotations:delete", + "test-channel", + "serial-001", + "reactions:flag.v1", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("API error"); + }); + }); +}); diff --git a/test/unit/commands/channels/annotations/get.test.ts b/test/unit/commands/channels/annotations/get.test.ts new file mode 100644 index 00000000..f7806241 --- /dev/null +++ b/test/unit/commands/channels/annotations/get.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { captureJsonLogs } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("channels:annotations:get command", () => { + beforeEach(() => { + getMockAblyRest(); + }); + + standardHelpTests("channels:annotations:get", import.meta.url); + standardArgValidationTests("channels:annotations:get", import.meta.url, { + requiredArgs: ["test-channel", "serial-001"], + }); + standardFlagTests("channels:annotations:get", import.meta.url, [ + "--json", + "--limit", + ]); + + describe("functionality", () => { + it("should get annotations for a message", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.annotations.get.mockResolvedValue({ + items: [ + { + id: "ann-get-001", + type: "reactions:flag.v1", + name: "thumbsup", + clientId: "user-1", + serial: "ann-serial-001", + timestamp: 1700000000000, + }, + ], + }); + + const { stdout } = await runCommand( + ["channels:annotations:get", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("test-channel"); + expect(channel.annotations.get).toHaveBeenCalledExactlyOnceWith( + "serial-001", + { + limit: 100, + }, + ); + expect(stdout).toContain("[1]"); + expect(stdout).toContain("ID:"); + expect(stdout).toContain("ann-get-001"); + expect(stdout).toContain("Timestamp:"); + expect(stdout).toContain("Channel:"); + expect(stdout).toContain("test-channel"); + expect(stdout).toContain("Type: reactions:flag.v1"); + expect(stdout).toContain("Name:"); + expect(stdout).toContain("thumbsup"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Serial:"); + expect(stdout).toContain("ann-serial-001"); + }); + + it("should display empty message when no annotations found", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.annotations.get.mockResolvedValue({ items: [] }); + + const { stdout } = await runCommand( + ["channels:annotations:get", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(stdout).toContain("No annotations found"); + }); + + it("should pass --limit flag", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:get", + "test-channel", + "serial-001", + "--limit", + "100", + ], + import.meta.url, + ); + + expect(channel.annotations.get).toHaveBeenCalledWith("serial-001", { + limit: 100, + }); + }); + + it("should output JSON when --json flag is used", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.annotations.get.mockResolvedValue({ + items: [ + { + type: "reactions:flag.v1", + name: "thumbsup", + clientId: "user-1", + }, + ], + }); + + const records = await captureJsonLogs(async () => { + await runCommand( + ["channels:annotations:get", "test-channel", "serial-001", "--json"], + import.meta.url, + ); + }); + + expect(records.length).toBeGreaterThanOrEqual(1); + const result = records[0]; + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "channels:annotations:get"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("channel", "test-channel"); + expect(result).toHaveProperty("serial", "serial-001"); + expect(result).toHaveProperty("annotations"); + expect(result.annotations as unknown[]).toHaveLength(1); + }); + + it("should display annotation details in human-readable output", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.annotations.get.mockResolvedValue({ + items: [ + { + id: "ann-detail-001", + type: "reactions:flag.v1", + name: "thumbsup", + clientId: "user-1", + count: 5, + serial: "ann-serial-detail", + data: { extra: "info" }, + timestamp: 1700000000000, + }, + ], + }); + + const { stdout } = await runCommand( + ["channels:annotations:get", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(stdout).toContain("Type: reactions:flag.v1"); + expect(stdout).toContain("Name:"); + expect(stdout).toContain("thumbsup"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Count:"); + expect(stdout).toContain("5"); + expect(stdout).toContain("Data:"); + expect(stdout).toContain("extra"); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.annotations.get.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["channels:annotations:get", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("API error"); + }); + }); +}); diff --git a/test/unit/commands/channels/annotations/index.test.ts b/test/unit/commands/channels/annotations/index.test.ts new file mode 100644 index 00000000..6727d265 --- /dev/null +++ b/test/unit/commands/channels/annotations/index.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { runCommand } from "@oclif/test"; +import { standardHelpTests } from "../../../../helpers/standard-tests.js"; + +describe("channels:annotations topic command", () => { + standardHelpTests("channels:annotations", import.meta.url); + + describe("argument validation", () => { + it("should handle unknown subcommand gracefully", async () => { + const { stdout } = await runCommand( + ["channels:annotations", "nonexistent"], + import.meta.url, + ); + expect(stdout).toBeDefined(); + }); + }); + + describe("functionality", () => { + it("should list available subcommands", async () => { + const { stdout } = await runCommand( + ["channels:annotations"], + import.meta.url, + ); + + expect(stdout).toContain("publish"); + expect(stdout).toContain("subscribe"); + expect(stdout).toContain("get"); + expect(stdout).toContain("delete"); + }); + }); + + describe("flags", () => { + it("should show usage information in help", async () => { + const { stdout } = await runCommand( + ["channels:annotations", "--help"], + import.meta.url, + ); + expect(stdout).toContain("USAGE"); + }); + }); + + describe("error handling", () => { + it("should not crash with no arguments", async () => { + const { stdout } = await runCommand( + ["channels:annotations"], + import.meta.url, + ); + expect(stdout).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/channels/annotations/publish.test.ts b/test/unit/commands/channels/annotations/publish.test.ts new file mode 100644 index 00000000..38aa0e32 --- /dev/null +++ b/test/unit/commands/channels/annotations/publish.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("channels:annotations:publish command", () => { + beforeEach(() => { + getMockAblyRest(); + }); + + standardHelpTests("channels:annotations:publish", import.meta.url); + standardArgValidationTests("channels:annotations:publish", import.meta.url, { + requiredArgs: ["test-channel", "serial-001", "reactions:flag.v1"], + }); + standardFlagTests("channels:annotations:publish", import.meta.url, [ + "--json", + "--name", + "--count", + "--data", + "--encoding", + ]); + + describe("functionality", () => { + it("should publish an annotation successfully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:flag.v1", + ], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("test-channel"); + expect(channel.annotations.publish).toHaveBeenCalledExactlyOnceWith( + "serial-001", + { + type: "reactions:flag.v1", + }, + ); + expect(stdout).toContain("Annotation published"); + }); + + it("should pass --name flag to annotation", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:flag.v1", + "--name", + "thumbsup", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:flag.v1", + name: "thumbsup", + }); + }); + + it("should pass --count flag to annotation", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:multiple.v1", + "--name", + "thumbsup", + "--count", + "3", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:multiple.v1", + name: "thumbsup", + count: 3, + }); + }); + + it("should parse JSON --data flag", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:flag.v1", + "--data", + '{"key":"value"}', + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:flag.v1", + data: { key: "value" }, + }); + }); + + it("should pass plain text --data flag", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:flag.v1", + "--data", + "plain-text", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:flag.v1", + data: "plain-text", + }); + }); + + it("should pass --encoding flag to annotation", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:flag.v1", + "--encoding", + "utf8", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:flag.v1", + encoding: "utf8", + }); + }); + + it("should succeed with total.v1 type (no extra params needed)", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:total.v1", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledExactlyOnceWith( + "serial-001", + { type: "reactions:total.v1" }, + ); + expect(stdout).toContain("Annotation published"); + }); + + it("should succeed with distinct.v1 type and --name", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:distinct.v1", + "--name", + "thumbsup", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:distinct.v1", + name: "thumbsup", + }); + expect(stdout).toContain("Annotation published"); + }); + + it("should succeed with unique.v1 type and --name", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:unique.v1", + "--name", + "thumbsup", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:unique.v1", + name: "thumbsup", + }); + expect(stdout).toContain("Annotation published"); + }); + + it("should succeed with multiple.v1 type and --name only (count defaults to 1)", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:multiple.v1", + "--name", + "thumbsup", + ], + import.meta.url, + ); + + expect(channel.annotations.publish).toHaveBeenCalledWith("serial-001", { + type: "reactions:multiple.v1", + name: "thumbsup", + }); + expect(stdout).toContain("Annotation published"); + }); + + it("should output JSON when --json flag is used", async () => { + const { stdout } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:flag.v1", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "channels:annotations:publish"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("channel", "test-channel"); + expect(result).toHaveProperty("serial", "serial-001"); + }); + }); + + describe("error handling", () => { + it("should error when --name is missing for distinct.v1 type", async () => { + const { error } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:distinct.v1", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("--name is required"); + }); + + it("should error when --name is missing for unique.v1 type", async () => { + const { error } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:unique.v1", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("--name is required"); + }); + + it("should error when --name is missing for multiple.v1 type", async () => { + const { error } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:multiple.v1", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("--name is required"); + }); + + it("should error on invalid annotation type format (no colon)", async () => { + const { error } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "invalidformat", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Invalid annotation type format"); + }); + + it("should error on invalid annotation type format (no dot)", async () => { + const { error } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:nodot", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Invalid annotation type format"); + }); + + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.annotations.publish.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + [ + "channels:annotations:publish", + "test-channel", + "serial-001", + "reactions:flag.v1", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("API error"); + }); + }); +}); diff --git a/test/unit/commands/channels/annotations/subscribe.test.ts b/test/unit/commands/channels/annotations/subscribe.test.ts new file mode 100644 index 00000000..8d54b10b --- /dev/null +++ b/test/unit/commands/channels/annotations/subscribe.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { captureJsonLogs } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("channels:annotations:subscribe command", () => { + let mockAnnotationCallback: ((annotation: unknown) => void) | null = null; + + beforeEach(() => { + mockAnnotationCallback = null; + + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + // Configure annotations.subscribe to capture the callback + channel.annotations.subscribe.mockImplementation( + async (typeOrCallback: unknown, callback?: unknown) => { + const cb = (callback ?? typeOrCallback) as ( + annotation: unknown, + ) => void; + mockAnnotationCallback = cb; + }, + ); + + // Configure connection.once to immediately call callback for 'connected' + mock.connection.once.mockImplementation( + (event: string, callback: () => void) => { + if (event === "connected") { + callback(); + } + }, + ); + + // Configure channel.once to immediately call callback for 'attached' + channel.once.mockImplementation((event: string, callback: () => void) => { + if (event === "attached") { + channel.state = "attached"; + callback(); + } + }); + }); + + standardHelpTests("channels:annotations:subscribe", import.meta.url); + standardArgValidationTests( + "channels:annotations:subscribe", + import.meta.url, + { + requiredArgs: ["test-channel"], + }, + ); + standardFlagTests("channels:annotations:subscribe", import.meta.url, [ + "--rewind", + "--type", + "--json", + ]); + + describe("functionality", () => { + it("should subscribe to annotations on a channel", async () => { + const mock = getMockAblyRealtime(); + + const { stdout } = await runCommand( + ["channels:annotations:subscribe", "test-channel"], + import.meta.url, + ); + + expect(stdout).toContain("test-channel"); + expect(mock.channels.get).toHaveBeenCalledWith( + "test-channel", + expect.objectContaining({ + modes: ["ANNOTATION_SUBSCRIBE"], + }), + ); + }); + + it("should receive and display annotations", async () => { + const commandPromise = runCommand( + ["channels:annotations:subscribe", "test-channel"], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockAnnotationCallback).not.toBeNull(); + }); + + mockAnnotationCallback!({ + id: "ann-id-001", + action: "annotation.create", + type: "reactions:flag.v1", + name: "thumbsup", + clientId: "user-1", + serial: "ann-serial-001", + messageSerial: "msg-serial-001", + timestamp: Date.now(), + }); + + const { stdout } = await commandPromise; + + expect(stdout).toContain("test-channel"); + expect(stdout).toContain("ID:"); + expect(stdout).toContain("ann-id-001"); + expect(stdout).toContain("Timestamp:"); + expect(stdout).toContain("Channel:"); + expect(stdout).toContain("Type: reactions:flag.v1"); + expect(stdout).toContain("Action:"); + expect(stdout).toContain("Name:"); + expect(stdout).toContain("thumbsup"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Serial:"); + expect(stdout).toContain("ann-serial-001"); + }); + + it("should run with --json flag without errors", async () => { + const { stdout, error } = await runCommand( + ["channels:annotations:subscribe", "test-channel", "--json"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + expect(stdout).toBeDefined(); + }); + + it("should emit JSON envelope for --json events", async () => { + const records = await captureJsonLogs(async () => { + const commandPromise = runCommand( + ["channels:annotations:subscribe", "test-channel", "--json"], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockAnnotationCallback).not.toBeNull(); + }); + + mockAnnotationCallback!({ + id: "ann-json-001", + action: "annotation.create", + type: "reactions:flag.v1", + name: "thumbsup", + clientId: "user-1", + serial: "ann-001", + messageSerial: "msg-001", + timestamp: Date.now(), + }); + + await commandPromise; + }); + + const events = records.filter( + (r) => + r.type === "event" && + (r as Record).annotation && + ((r as Record).annotation as Record) + .channel === "test-channel", + ); + expect(events.length).toBeGreaterThan(0); + const record = events[0]; + expect(record).toHaveProperty("type", "event"); + expect(record).toHaveProperty( + "command", + "channels:annotations:subscribe", + ); + expect(record).toHaveProperty("annotation.channel", "test-channel"); + expect(record).toHaveProperty("annotation.id", "ann-json-001"); + expect(record).toHaveProperty("annotation.serial"); + }); + + it("should include annotationType in JSON event envelope", async () => { + const records = await captureJsonLogs(async () => { + const commandPromise = runCommand( + ["channels:annotations:subscribe", "test-channel", "--json"], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockAnnotationCallback).not.toBeNull(); + }); + + mockAnnotationCallback!({ + id: "ann-type-001", + action: "annotation.create", + type: "reactions:flag.v1", + name: "thumbsup", + clientId: "user-1", + serial: "ann-001", + messageSerial: "msg-001", + timestamp: Date.now(), + }); + + await commandPromise; + }); + + const events = records.filter( + (r) => + r.type === "event" && + (r as Record).annotation && + ((r as Record).annotation as Record) + .channel === "test-channel", + ); + expect(events.length).toBeGreaterThan(0); + expect(events[0]).toHaveProperty( + "annotation.annotationType", + "reactions:flag.v1", + ); + // "type" key in data would collide with envelope — must use "annotationType" + expect(events[0].type).toBe("event"); + }); + + it("should display annotation data in human-readable output", async () => { + const commandPromise = runCommand( + ["channels:annotations:subscribe", "test-channel"], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockAnnotationCallback).not.toBeNull(); + }); + + mockAnnotationCallback!({ + id: "ann-data-001", + action: "annotation.create", + type: "reactions:flag.v1", + name: "thumbsup", + clientId: "user-1", + serial: "ann-serial-001", + messageSerial: "msg-serial-001", + timestamp: Date.now(), + data: { emoji: "thumbs-up", custom: true }, + }); + + const { stdout } = await commandPromise; + + expect(stdout).toContain("Data:"); + expect(stdout).toContain("emoji"); + expect(stdout).toContain("thumbs-up"); + }); + + it("should pass --type filter to subscribe", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:annotations:subscribe", + "test-channel", + "--type", + "reactions:flag.v1", + ], + import.meta.url, + ); + + expect(channel.annotations.subscribe).toHaveBeenCalledWith( + "reactions:flag.v1", + expect.any(Function), + ); + }); + }); + + describe("error handling", () => { + it("should handle missing mock client in test mode", async () => { + if (globalThis.__TEST_MOCKS__) { + delete globalThis.__TEST_MOCKS__.ablyRealtimeMock; + } + + const { error } = await runCommand( + ["channels:annotations:subscribe", "test-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/No mock|client/i); + }); + }); +}); diff --git a/test/unit/commands/channels/delete.test.ts b/test/unit/commands/channels/delete.test.ts index ddb1f62d..c540d011 100644 --- a/test/unit/commands/channels/delete.test.ts +++ b/test/unit/commands/channels/delete.test.ts @@ -14,7 +14,7 @@ describe("channels:delete command", () => { standardHelpTests("channels:delete", import.meta.url); standardArgValidationTests("channels:delete", import.meta.url, { - requiredArgs: ["test-channel"], + requiredArgs: ["test-channel", "serial-001"], }); standardFlagTests("channels:delete", import.meta.url, [ "--json", diff --git a/test/unit/utils/annotations.test.ts b/test/unit/utils/annotations.test.ts new file mode 100644 index 00000000..dc9c6a94 --- /dev/null +++ b/test/unit/utils/annotations.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { + extractSummarizationType, + validateAnnotationParams, +} from "../../../src/utils/annotations.js"; + +describe("annotations utility", () => { + describe("extractSummarizationType", () => { + it('should extract "flag" from "reactions:flag.v1"', () => { + expect(extractSummarizationType("reactions:flag.v1")).toBe("flag"); + }); + + it('should extract "distinct" from "reactions:distinct.v1"', () => { + expect(extractSummarizationType("reactions:distinct.v1")).toBe( + "distinct", + ); + }); + + it('should extract "unique" from "reactions:unique.v1"', () => { + expect(extractSummarizationType("reactions:unique.v1")).toBe("unique"); + }); + + it('should extract "multiple" from "reactions:multiple.v1"', () => { + expect(extractSummarizationType("reactions:multiple.v1")).toBe( + "multiple", + ); + }); + + it('should extract "total" from "custom:total.v2"', () => { + expect(extractSummarizationType("custom:total.v2")).toBe("total"); + }); + + it("should handle custom namespaces", () => { + expect(extractSummarizationType("my-namespace:flag.v1")).toBe("flag"); + }); + + it("should throw on missing colon", () => { + expect(() => extractSummarizationType("invalidformat")).toThrow( + "Invalid annotation type format", + ); + }); + + it("should throw on missing dot", () => { + expect(() => extractSummarizationType("reactions:nodot")).toThrow( + "Invalid annotation type format", + ); + }); + + it("should throw on empty string", () => { + expect(() => extractSummarizationType("")).toThrow( + "Invalid annotation type format", + ); + }); + }); + + describe("validateAnnotationParams", () => { + it("should return no errors for total type", () => { + expect(validateAnnotationParams("total", {})).toEqual([]); + }); + + it("should return no errors for flag type", () => { + expect(validateAnnotationParams("flag", {})).toEqual([]); + }); + + it("should require --name for distinct type", () => { + const errors = validateAnnotationParams("distinct", {}); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("--name is required"); + expect(errors[0]).toContain("distinct"); + }); + + it("should pass for distinct type with name", () => { + expect( + validateAnnotationParams("distinct", { name: "thumbsup" }), + ).toEqual([]); + }); + + it("should require --name for unique type", () => { + const errors = validateAnnotationParams("unique", {}); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("--name is required"); + expect(errors[0]).toContain("unique"); + }); + + it("should pass for unique type with name", () => { + expect(validateAnnotationParams("unique", { name: "thumbsup" })).toEqual( + [], + ); + }); + + it("should require --name for multiple type", () => { + const errors = validateAnnotationParams("multiple", {}); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("--name is required"); + expect(errors[0]).toContain("multiple"); + }); + + it("should pass for multiple type with name only (count defaults to 1)", () => { + expect( + validateAnnotationParams("multiple", { name: "thumbsup" }), + ).toEqual([]); + }); + + it("should pass for unknown future types (forward compatibility)", () => { + expect(validateAnnotationParams("unknown-future-type", {})).toEqual([]); + }); + }); +});