From e4f9e11553a899ae475dceb0e5d6a7e9332e63dc Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 12 Mar 2026 21:27:48 +0000 Subject: [PATCH] Add support for Chat message updates and deletes --- README.md | 80 ++++ src/commands/rooms/messages/delete.ts | 143 +++++++ src/commands/rooms/messages/history.ts | 8 +- src/commands/rooms/messages/index.ts | 2 + .../rooms/messages/reactions/remove.ts | 2 +- src/commands/rooms/messages/reactions/send.ts | 4 +- .../rooms/messages/reactions/subscribe.ts | 2 +- src/commands/rooms/messages/send.ts | 6 +- src/commands/rooms/messages/update.ts | 236 ++++++++++++ src/commands/rooms/occupancy/get.ts | 6 +- src/commands/rooms/occupancy/subscribe.ts | 2 +- src/commands/rooms/reactions/send.ts | 11 +- src/commands/rooms/reactions/subscribe.ts | 2 +- src/commands/rooms/typing/keystroke.ts | 6 +- src/commands/rooms/typing/subscribe.ts | 6 +- test/helpers/mock-ably-chat.ts | 16 + .../commands/rooms/messages/delete.test.ts | 172 +++++++++ .../commands/rooms/messages/update.test.ts | 349 ++++++++++++++++++ 18 files changed, 1038 insertions(+), 15 deletions(-) create mode 100644 src/commands/rooms/messages/delete.ts create mode 100644 src/commands/rooms/messages/update.ts create mode 100644 test/unit/commands/rooms/messages/delete.test.ts create mode 100644 test/unit/commands/rooms/messages/update.test.ts diff --git a/README.md b/README.md index 9c27fc82..dc486174 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ $ ably-interactive * [`ably rooms`](#ably-rooms) * [`ably rooms list`](#ably-rooms-list) * [`ably rooms messages`](#ably-rooms-messages) +* [`ably rooms messages delete ROOM SERIAL`](#ably-rooms-messages-delete-room-serial) * [`ably rooms messages history ROOM`](#ably-rooms-messages-history-room) * [`ably rooms messages reactions`](#ably-rooms-messages-reactions) * [`ably rooms messages reactions remove ROOM MESSAGESERIAL REACTION`](#ably-rooms-messages-reactions-remove-room-messageserial-reaction) @@ -164,6 +165,7 @@ $ ably-interactive * [`ably rooms messages reactions subscribe ROOM`](#ably-rooms-messages-reactions-subscribe-room) * [`ably rooms messages send ROOM TEXT`](#ably-rooms-messages-send-room-text) * [`ably rooms messages subscribe ROOMS`](#ably-rooms-messages-subscribe-rooms) +* [`ably rooms messages update ROOM SERIAL TEXT`](#ably-rooms-messages-update-room-serial-text) * [`ably rooms occupancy`](#ably-rooms-occupancy) * [`ably rooms occupancy get ROOM`](#ably-rooms-occupancy-get-room) * [`ably rooms occupancy subscribe ROOM`](#ably-rooms-occupancy-subscribe-room) @@ -3051,10 +3053,47 @@ EXAMPLES $ ably rooms messages subscribe my-room $ ably rooms messages history my-room + + $ ably rooms messages update my-room "serial" "Updated text" + + $ ably rooms messages delete my-room "serial" ``` _See code: [src/commands/rooms/messages/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/messages/index.ts)_ +## `ably rooms messages delete ROOM SERIAL` + +Delete a message in an Ably Chat room + +``` +USAGE + $ ably rooms messages delete ROOM SERIAL [-v] [--json | --pretty-json] [--client-id ] [--description ] + +ARGUMENTS + ROOM The room containing the message to delete + SERIAL The serial of the message to delete + +FLAGS + -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. + --description= Description of the delete operation + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Delete a message in an Ably Chat room + +EXAMPLES + $ ably rooms messages delete my-room "serial-001" + + $ ably rooms messages delete my-room "serial-001" --description "spam removal" + + $ ably rooms messages delete my-room "serial-001" --json +``` + +_See code: [src/commands/rooms/messages/delete.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/messages/delete.ts)_ + ## `ably rooms messages history ROOM` Get historical messages from an Ably Chat room @@ -3326,6 +3365,47 @@ EXAMPLES _See code: [src/commands/rooms/messages/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/messages/subscribe.ts)_ +## `ably rooms messages update ROOM SERIAL TEXT` + +Update a message in an Ably Chat room + +``` +USAGE + $ ably rooms messages update ROOM SERIAL TEXT [-v] [--json | --pretty-json] [--client-id ] [--description ] + [--headers ] [--metadata ] + +ARGUMENTS + ROOM The room containing the message to update + SERIAL The serial of the message to update + TEXT The new message text + +FLAGS + -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. + --description= Description of the update operation + --headers= Additional headers for the message (JSON format) + --json Output in JSON format + --metadata= Additional metadata for the message (JSON format) + --pretty-json Output in colorized JSON format + +DESCRIPTION + Update a message in an Ably Chat room + +EXAMPLES + $ ably rooms messages update my-room "serial-001" "Updated text" + + $ ably rooms messages update my-room "serial-001" "Updated text" --description "typo fix" + + $ ably rooms messages update my-room "serial-001" "Updated text" --metadata '{"edited":true}' + + $ ably rooms messages update my-room "serial-001" "Updated text" --headers '{"source":"cli"}' + + $ ably rooms messages update my-room "serial-001" "Updated text" --json +``` + +_See code: [src/commands/rooms/messages/update.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/messages/update.ts)_ + ## `ably rooms occupancy` Commands for monitoring room occupancy diff --git a/src/commands/rooms/messages/delete.ts b/src/commands/rooms/messages/delete.ts new file mode 100644 index 00000000..a0cfe5ae --- /dev/null +++ b/src/commands/rooms/messages/delete.ts @@ -0,0 +1,143 @@ +import { Args, Flags } from "@oclif/core"; +import type { OperationDetails } from "@ably/chat"; + +import { productApiFlags, clientIdFlag } from "../../../flags.js"; +import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { + formatProgress, + formatSuccess, + formatResource, +} from "../../../utils/output.js"; + +export default class MessagesDelete extends ChatBaseCommand { + static override args = { + room: Args.string({ + description: "The room containing the message to delete", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to delete", + required: true, + }), + }; + + static override description = "Delete a message in an Ably Chat room"; + + static override examples = [ + '$ ably rooms messages delete my-room "serial-001"', + '$ ably rooms messages delete my-room "serial-001" --description "spam removal"', + '$ ably rooms messages delete my-room "serial-001" --json', + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + description: Flags.string({ + description: "Description of the delete operation", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(MessagesDelete); + + try { + const chatClient = await this.createChatClient(flags); + + if (!chatClient) { + return this.fail( + "Failed to create Chat client", + flags, + "roomMessageDelete", + ); + } + + this.setupConnectionStateLogging(chatClient.realtime, flags); + + // Get the room and attach + this.logCliEvent( + flags, + "room", + "gettingRoom", + `Getting room handle for ${args.room}`, + ); + const room = await chatClient.rooms.get(args.room); + this.logCliEvent( + flags, + "room", + "gotRoom", + `Got room handle for ${args.room}`, + ); + + this.logCliEvent( + flags, + "room", + "attaching", + `Attaching to room ${args.room}`, + ); + await room.attach(); + this.logCliEvent( + flags, + "room", + "attached", + `Successfully attached to room ${args.room}`, + ); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + "Deleting message " + + formatResource(args.serial) + + " in room " + + formatResource(args.room), + ), + ); + } + + // Build operation details + const details: OperationDetails | undefined = flags.description + ? { description: flags.description } + : undefined; + + this.logCliEvent( + flags, + "roomMessageDelete", + "deleting", + `Deleting message ${args.serial} from room ${args.room}`, + { room: args.room, serial: args.serial }, + ); + + const result = await room.messages.delete(args.serial, details); + + this.logCliEvent( + flags, + "roomMessageDelete", + "messageDeleted", + `Message ${args.serial} deleted from room ${args.room}`, + { room: args.room, serial: args.serial }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + room: args.room, + serial: args.serial, + versionSerial: result.version.serial, + }, + flags, + ); + } else { + this.log( + formatSuccess( + `Message ${formatResource(args.serial)} deleted from room ${formatResource(args.room)}.`, + ), + ); + this.log(` Version serial: ${formatResource(result.version.serial)}`); + } + } catch (error) { + this.fail(error, flags, "roomMessageDelete", { + room: args.room, + serial: args.serial, + }); + } + } +} diff --git a/src/commands/rooms/messages/history.ts b/src/commands/rooms/messages/history.ts index 13f9c7cf..0badfa29 100644 --- a/src/commands/rooms/messages/history.ts +++ b/src/commands/rooms/messages/history.ts @@ -66,7 +66,11 @@ export default class MessagesHistory extends ChatBaseCommand { const chatClient = await this.createChatClient(flags); if (!chatClient) { - this.fail("Failed to create Chat client", flags, "roomMessageHistory"); + return this.fail( + "Failed to create Chat client", + flags, + "roomMessageHistory", + ); } // Get the room @@ -122,7 +126,7 @@ export default class MessagesHistory extends ChatBaseCommand { historyParams.end !== undefined && historyParams.start > historyParams.end ) { - this.fail( + return this.fail( "--start must be earlier than or equal to --end", flags, "roomMessageHistory", diff --git a/src/commands/rooms/messages/index.ts b/src/commands/rooms/messages/index.ts index 90511770..fd985db8 100644 --- a/src/commands/rooms/messages/index.ts +++ b/src/commands/rooms/messages/index.ts @@ -11,5 +11,7 @@ export default class MessagesIndex extends BaseTopicCommand { '<%= config.bin %> <%= command.id %> send my-room "Hello world!"', "<%= config.bin %> <%= command.id %> subscribe my-room", "<%= config.bin %> <%= command.id %> history my-room", + '<%= config.bin %> <%= command.id %> update my-room "serial" "Updated text"', + '<%= config.bin %> <%= command.id %> delete my-room "serial"', ]; } diff --git a/src/commands/rooms/messages/reactions/remove.ts b/src/commands/rooms/messages/reactions/remove.ts index 53fb358f..8b119ad0 100644 --- a/src/commands/rooms/messages/reactions/remove.ts +++ b/src/commands/rooms/messages/reactions/remove.ts @@ -50,7 +50,7 @@ export default class MessagesReactionsRemove extends ChatBaseCommand { const chatClient = await this.createChatClient(flags); if (!chatClient) { - this.fail( + return this.fail( "Failed to create Chat client", flags, "roomMessageReactionRemove", diff --git a/src/commands/rooms/messages/reactions/send.ts b/src/commands/rooms/messages/reactions/send.ts index fe3af2da..73cb05d1 100644 --- a/src/commands/rooms/messages/reactions/send.ts +++ b/src/commands/rooms/messages/reactions/send.ts @@ -59,7 +59,7 @@ export default class MessagesReactionsSend extends ChatBaseCommand { flags.count !== undefined && flags.count <= 0 ) { - this.fail( + return this.fail( "Count must be a positive integer for Multiple type reactions", flags, "roomMessageReactionSend", @@ -71,7 +71,7 @@ export default class MessagesReactionsSend extends ChatBaseCommand { this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail( + return this.fail( "Failed to create Chat client", flags, "roomMessageReactionSend", diff --git a/src/commands/rooms/messages/reactions/subscribe.ts b/src/commands/rooms/messages/reactions/subscribe.ts index e79d3173..c487f341 100644 --- a/src/commands/rooms/messages/reactions/subscribe.ts +++ b/src/commands/rooms/messages/reactions/subscribe.ts @@ -60,7 +60,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail( + return this.fail( "Failed to initialize clients", flags, "roomMessageReactionSubscribe", diff --git a/src/commands/rooms/messages/send.ts b/src/commands/rooms/messages/send.ts index c68514e6..bd39c830 100644 --- a/src/commands/rooms/messages/send.ts +++ b/src/commands/rooms/messages/send.ts @@ -100,7 +100,11 @@ export default class MessagesSend extends ChatBaseCommand { this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail("Failed to create Chat client", flags, "roomMessageSend"); + return this.fail( + "Failed to create Chat client", + flags, + "roomMessageSend", + ); } // Set up connection state logging diff --git a/src/commands/rooms/messages/update.ts b/src/commands/rooms/messages/update.ts new file mode 100644 index 00000000..b836ff47 --- /dev/null +++ b/src/commands/rooms/messages/update.ts @@ -0,0 +1,236 @@ +import { Args, Flags } from "@oclif/core"; +import type { OperationDetails, UpdateMessageParams } from "@ably/chat"; + +import { errorMessage } from "../../../utils/errors.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; +import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { + formatProgress, + formatSuccess, + formatResource, +} from "../../../utils/output.js"; + +export default class MessagesUpdate extends ChatBaseCommand { + static override args = { + room: Args.string({ + description: "The room containing the message to update", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to update", + required: true, + }), + text: Args.string({ + description: "The new message text", + required: true, + }), + }; + + static override description = "Update a message in an Ably Chat room"; + + static override examples = [ + '$ ably rooms messages update my-room "serial-001" "Updated text"', + '$ ably rooms messages update my-room "serial-001" "Updated text" --description "typo fix"', + '$ ably rooms messages update my-room "serial-001" "Updated text" --metadata \'{"edited":true}\'', + '$ ably rooms messages update my-room "serial-001" "Updated text" --headers \'{"source":"cli"}\'', + '$ ably rooms messages update my-room "serial-001" "Updated text" --json', + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + description: Flags.string({ + description: "Description of the update operation", + }), + headers: Flags.string({ + description: "Additional headers for the message (JSON format)", + }), + metadata: Flags.string({ + description: "Additional metadata for the message (JSON format)", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(MessagesUpdate); + + try { + // Parse and validate metadata before any client setup + let metadata; + if (flags.metadata !== undefined) { + try { + metadata = JSON.parse(flags.metadata); + } catch (error) { + return this.fail( + `Invalid metadata JSON: ${errorMessage(error)}`, + flags, + "roomMessageUpdate", + ); + } + + if ( + typeof metadata !== "object" || + metadata === null || + Array.isArray(metadata) + ) { + return this.fail( + "Metadata must be a JSON object", + flags, + "roomMessageUpdate", + ); + } + + this.logCliEvent( + flags, + "roomMessageUpdate", + "metadataParsed", + "Message metadata parsed", + { metadata }, + ); + } + + // Parse and validate headers before any client setup + let headers; + if (flags.headers !== undefined) { + try { + headers = JSON.parse(flags.headers); + } catch (error) { + return this.fail( + `Invalid headers JSON: ${errorMessage(error)}`, + flags, + "roomMessageUpdate", + ); + } + + if ( + typeof headers !== "object" || + headers === null || + Array.isArray(headers) + ) { + return this.fail( + "Headers must be a JSON object", + flags, + "roomMessageUpdate", + ); + } + + this.logCliEvent( + flags, + "roomMessageUpdate", + "headersParsed", + "Message headers parsed", + { headers }, + ); + } + + const chatClient = await this.createChatClient(flags); + + if (!chatClient) { + return this.fail( + "Failed to create Chat client", + flags, + "roomMessageUpdate", + ); + } + + this.setupConnectionStateLogging(chatClient.realtime, flags); + + // Get the room and attach + this.logCliEvent( + flags, + "room", + "gettingRoom", + `Getting room handle for ${args.room}`, + ); + const room = await chatClient.rooms.get(args.room); + this.logCliEvent( + flags, + "room", + "gotRoom", + `Got room handle for ${args.room}`, + ); + + this.logCliEvent( + flags, + "room", + "attaching", + `Attaching to room ${args.room}`, + ); + await room.attach(); + this.logCliEvent( + flags, + "room", + "attached", + `Successfully attached to room ${args.room}`, + ); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + "Updating message " + + formatResource(args.serial) + + " in room " + + formatResource(args.room), + ), + ); + } + + // Build update params + const updateParams: UpdateMessageParams = { + text: args.text, + ...(metadata ? { metadata } : {}), + ...(headers ? { headers } : {}), + }; + + // Build operation details + const details: OperationDetails | undefined = flags.description + ? { description: flags.description } + : undefined; + + this.logCliEvent( + flags, + "roomMessageUpdate", + "updating", + `Updating message ${args.serial} in room ${args.room}`, + { room: args.room, serial: args.serial }, + ); + + const result = await room.messages.update( + args.serial, + updateParams, + details, + ); + + this.logCliEvent( + flags, + "roomMessageUpdate", + "messageUpdated", + `Message ${args.serial} updated in room ${args.room}`, + { room: args.room, serial: args.serial }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + room: args.room, + serial: args.serial, + updatedText: result.text, + versionSerial: result.version.serial, + }, + flags, + ); + } else { + this.log( + formatSuccess( + `Message ${formatResource(args.serial)} updated in room ${formatResource(args.room)}.`, + ), + ); + this.log(` Version serial: ${formatResource(result.version.serial)}`); + } + } catch (error) { + this.fail(error, flags, "roomMessageUpdate", { + room: args.room, + serial: args.serial, + }); + } + } +} diff --git a/src/commands/rooms/occupancy/get.ts b/src/commands/rooms/occupancy/get.ts index 4f1ff210..0f55d27f 100644 --- a/src/commands/rooms/occupancy/get.ts +++ b/src/commands/rooms/occupancy/get.ts @@ -37,7 +37,11 @@ export default class RoomsOccupancyGet extends ChatBaseCommand { this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail("Failed to create Chat client", flags, "roomOccupancyGet"); + return this.fail( + "Failed to create Chat client", + flags, + "roomOccupancyGet", + ); } const { room: roomName } = args; diff --git a/src/commands/rooms/occupancy/subscribe.ts b/src/commands/rooms/occupancy/subscribe.ts index 4c65b315..3cbc77e4 100644 --- a/src/commands/rooms/occupancy/subscribe.ts +++ b/src/commands/rooms/occupancy/subscribe.ts @@ -61,7 +61,7 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail( + return this.fail( "Failed to create Chat client", flags, "roomOccupancySubscribe", diff --git a/src/commands/rooms/reactions/send.ts b/src/commands/rooms/reactions/send.ts index 16a43b32..6878b756 100644 --- a/src/commands/rooms/reactions/send.ts +++ b/src/commands/rooms/reactions/send.ts @@ -70,9 +70,14 @@ export default class RoomsReactionsSend extends ChatBaseCommand { this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail("Failed to create Chat client", flags, "roomReactionSend", { - room: roomName, - }); + return this.fail( + "Failed to create Chat client", + flags, + "roomReactionSend", + { + room: roomName, + }, + ); } // Set up connection state logging diff --git a/src/commands/rooms/reactions/subscribe.ts b/src/commands/rooms/reactions/subscribe.ts index e2f5bbe1..97f5d709 100644 --- a/src/commands/rooms/reactions/subscribe.ts +++ b/src/commands/rooms/reactions/subscribe.ts @@ -44,7 +44,7 @@ export default class RoomsReactionsSubscribe extends ChatBaseCommand { this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail( + return this.fail( "Failed to initialize clients", flags, "roomReactionSubscribe", diff --git a/src/commands/rooms/typing/keystroke.ts b/src/commands/rooms/typing/keystroke.ts index dba4ada5..d297c4d3 100644 --- a/src/commands/rooms/typing/keystroke.ts +++ b/src/commands/rooms/typing/keystroke.ts @@ -67,7 +67,11 @@ export default class TypingKeystroke extends ChatBaseCommand { // Create Chat client this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail("Failed to initialize clients", flags, "roomTypingKeystroke"); + return this.fail( + "Failed to initialize clients", + flags, + "roomTypingKeystroke", + ); } const { room: roomName } = args; diff --git a/src/commands/rooms/typing/subscribe.ts b/src/commands/rooms/typing/subscribe.ts index 82c49168..9d9a66c1 100644 --- a/src/commands/rooms/typing/subscribe.ts +++ b/src/commands/rooms/typing/subscribe.ts @@ -39,7 +39,11 @@ export default class TypingSubscribe extends ChatBaseCommand { // Create Chat client this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - this.fail("Failed to initialize clients", flags, "roomTypingSubscribe"); + return this.fail( + "Failed to initialize clients", + flags, + "roomTypingSubscribe", + ); } const { room: roomName } = args; diff --git a/test/helpers/mock-ably-chat.ts b/test/helpers/mock-ably-chat.ts index 803b753a..b4993f2f 100644 --- a/test/helpers/mock-ably-chat.ts +++ b/test/helpers/mock-ably-chat.ts @@ -55,6 +55,8 @@ export interface MockRoomMessages { subscribe: Mock; send: Mock; get: Mock; + update: Mock; + delete: Mock; reactions: MockMessageReactions; // Internal emitter for simulating events _emitter: AblyEventEmitter; @@ -236,6 +238,20 @@ function createMockRoomMessages(): MockRoomMessages { createdAt: Date.now(), }), get: vi.fn().mockResolvedValue({ items: [] }), + update: vi.fn().mockResolvedValue({ + serial: "mock-serial", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "mock-version-serial", timestamp: new Date() }, + }), + delete: vi.fn().mockResolvedValue({ + serial: "mock-serial", + clientId: "mock-client-id", + text: "", + timestamp: new Date(), + version: { serial: "mock-version-serial", timestamp: new Date() }, + }), reactions: createMockMessageReactions(), _emitter: emitter, _emit: (message: Message) => { diff --git a/test/unit/commands/rooms/messages/delete.test.ts b/test/unit/commands/rooms/messages/delete.test.ts new file mode 100644 index 00000000..39d71b37 --- /dev/null +++ b/test/unit/commands/rooms/messages/delete.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; +import { captureJsonLogs } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("rooms:messages:delete command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + standardHelpTests("rooms:messages:delete", import.meta.url); + standardArgValidationTests("rooms:messages:delete", import.meta.url, { + requiredArgs: ["test-room", "serial-001"], + }); + standardFlagTests("rooms:messages:delete", import.meta.url, [ + "--json", + "--description", + ]); + + describe("functionality", () => { + it("should delete a message successfully", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.delete.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + const { stdout } = await runCommand( + ["rooms:messages:delete", "test-room", "serial-001"], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.messages.delete).toHaveBeenCalledWith( + "serial-001", + undefined, + ); + expect(stdout).toContain("deleted"); + expect(stdout).toContain("test-room"); + }); + + it("should pass description as OperationDetails", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.delete.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + await runCommand( + [ + "rooms:messages:delete", + "test-room", + "serial-001", + "--description", + "spam-removal", + ], + import.meta.url, + ); + + expect(room.messages.delete).toHaveBeenCalledWith("serial-001", { + description: "spam-removal", + }); + }); + + it("should not pass details when no description", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.delete.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + await runCommand( + ["rooms:messages:delete", "test-room", "serial-001"], + import.meta.url, + ); + + expect(room.messages.delete).toHaveBeenCalledWith( + "serial-001", + undefined, + ); + }); + + it("should emit JSON envelope with --json", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.delete.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + const records = await captureJsonLogs(async () => { + await runCommand( + ["rooms:messages:delete", "test-room", "serial-001", "--json"], + import.meta.url, + ); + }); + + const results = records.filter( + (r) => r.type === "result" && r.room === "test-room", + ); + expect(results.length).toBeGreaterThan(0); + const record = results[0]; + expect(record).toHaveProperty("type", "result"); + expect(record).toHaveProperty("command", "rooms:messages:delete"); + expect(record).toHaveProperty("success", true); + expect(record).toHaveProperty("room", "test-room"); + expect(record).toHaveProperty("serial", "serial-001"); + expect(record).toHaveProperty("versionSerial", "version-serial-001"); + }); + + it("should display version serial in human output", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.delete.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + const { stdout } = await runCommand( + ["rooms:messages:delete", "test-room", "serial-001"], + import.meta.url, + ); + + expect(stdout).toContain("version-serial-001"); + }); + }); + + describe("error handling", () => { + it("should handle API error", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.delete.mockRejectedValue(new Error("Delete failed")); + + const { error } = await runCommand( + ["rooms:messages:delete", "test-room", "serial-001"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Delete failed"); + }); + }); +}); diff --git a/test/unit/commands/rooms/messages/update.test.ts b/test/unit/commands/rooms/messages/update.test.ts new file mode 100644 index 00000000..34fea1cd --- /dev/null +++ b/test/unit/commands/rooms/messages/update.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; +import { captureJsonLogs } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("rooms:messages:update command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + standardHelpTests("rooms:messages:update", import.meta.url); + standardArgValidationTests("rooms:messages:update", import.meta.url, { + requiredArgs: ["test-room", "serial-001", "updated-text"], + }); + standardFlagTests("rooms:messages:update", import.meta.url, [ + "--json", + "--metadata", + "--headers", + "--description", + ]); + + describe("functionality", () => { + it("should update a message successfully", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + const { stdout } = await runCommand( + ["rooms:messages:update", "test-room", "serial-001", "updated-text"], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.messages.update).toHaveBeenCalledWith( + "serial-001", + { text: "updated-text" }, + undefined, + ); + expect(stdout).toContain("updated"); + expect(stdout).toContain("test-room"); + }); + + it("should pass metadata when --metadata provided", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--metadata", + '{"edited":true}', + ], + import.meta.url, + ); + + expect(room.messages.update).toHaveBeenCalledWith( + "serial-001", + { text: "updated-text", metadata: { edited: true } }, + undefined, + ); + }); + + it("should pass headers when --headers provided", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--headers", + '{"source":"cli"}', + ], + import.meta.url, + ); + + expect(room.messages.update).toHaveBeenCalledWith( + "serial-001", + { text: "updated-text", headers: { source: "cli" } }, + undefined, + ); + }); + + it("should pass description as OperationDetails", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--description", + "typo-fix", + ], + import.meta.url, + ); + + expect(room.messages.update).toHaveBeenCalledWith( + "serial-001", + { text: "updated-text" }, + { description: "typo-fix" }, + ); + }); + + it("should not pass details when no description", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + await runCommand( + ["rooms:messages:update", "test-room", "serial-001", "updated-text"], + import.meta.url, + ); + + expect(room.messages.update).toHaveBeenCalledWith( + "serial-001", + { text: "updated-text" }, + undefined, + ); + }); + + it("should emit JSON envelope with --json", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + const records = await captureJsonLogs(async () => { + await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--json", + ], + import.meta.url, + ); + }); + + const results = records.filter( + (r) => r.type === "result" && r.room === "test-room", + ); + expect(results.length).toBeGreaterThan(0); + const record = results[0]; + expect(record).toHaveProperty("type", "result"); + expect(record).toHaveProperty("command", "rooms:messages:update"); + expect(record).toHaveProperty("success", true); + expect(record).toHaveProperty("room", "test-room"); + expect(record).toHaveProperty("serial", "serial-001"); + expect(record).toHaveProperty("versionSerial", "version-serial-001"); + }); + + it("should display version serial in human output", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockResolvedValue({ + serial: "serial-001", + clientId: "mock-client-id", + text: "updated-text", + timestamp: new Date(), + version: { serial: "version-serial-001", timestamp: new Date() }, + }); + + const { stdout } = await runCommand( + ["rooms:messages:update", "test-room", "serial-001", "updated-text"], + import.meta.url, + ); + + expect(stdout).toContain("version-serial-001"); + }); + }); + + describe("error handling", () => { + it("should handle invalid metadata JSON", async () => { + const { error } = await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--metadata", + "invalid-json", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Invalid metadata JSON/i); + }); + + it("should reject non-object metadata", async () => { + const { error } = await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--metadata", + "42", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Metadata must be a JSON object/i); + }); + + it("should reject array metadata", async () => { + const { error } = await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--metadata", + "[1,2,3]", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Metadata must be a JSON object/i); + }); + + it("should handle invalid headers JSON", async () => { + const { error } = await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--headers", + "invalid-json", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Invalid headers JSON/i); + }); + + it("should reject non-object headers", async () => { + const { error } = await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--headers", + "42", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Headers must be a JSON object/i); + }); + + it("should reject array headers", async () => { + const { error } = await runCommand( + [ + "rooms:messages:update", + "test-room", + "serial-001", + "updated-text", + "--headers", + "[1,2,3]", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Headers must be a JSON object/i); + }); + + it("should handle API error", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.update.mockRejectedValue(new Error("Update failed")); + + const { error } = await runCommand( + ["rooms:messages:update", "test-room", "serial-001", "updated-text"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Update failed"); + }); + }); +});