diff --git a/README.md b/README.md index 8bd2c775..a0910c2d 100644 --- a/README.md +++ b/README.md @@ -3581,9 +3581,9 @@ Publish a push notification to a device, client, or channel ``` USAGE $ ably push publish [-v] [--json | --pretty-json] [--device-id | --client-id | --recipient - ] [--channel ] [--title ] [--body ] [--sound ] [--icon ] [--badge ] - [--data ] [--collapse-key ] [--ttl ] [--payload ] [--apns ] [--fcm ] - [--web ] [-f] + ] [--channel ] [--message ] [--title ] [--body ] [--sound ] [--icon + ] [--badge ] [--data ] [--collapse-key ] [--ttl ] [--payload ] [--apns + ] [--fcm ] [--web ] [-f] FLAGS -f, --force Skip confirmation prompt (required with --json) @@ -3600,6 +3600,8 @@ FLAGS --fcm= FCM-specific override as JSON --icon= Notification icon --json Output in JSON format + --message= Realtime message data to include alongside the push notification (only applies when + publishing via --channel) --payload= Full notification payload as JSON (overrides convenience flags) --pretty-json Output in colorized JSON format --recipient= Raw recipient JSON for advanced targeting @@ -3632,6 +3634,10 @@ EXAMPLES $ ably push publish --channel my-channel --payload ./notification.json + $ ably push publish --channel my-channel --title Hello --body World --message 'Hello from push' + + $ ably push publish --channel my-channel --title Hello --body World --message '{"event":"push","text":"Hello"}' + $ ably push publish --recipient '{"transportType":"apns","deviceToken":"token123"}' --title Hello --body World $ ably push publish --device-id device-123 --title Hello --body World --json diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index f22d57bb..eab732dd 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -23,6 +23,8 @@ export default class PushPublish extends AblyBaseCommand { '<%= config.bin %> <%= command.id %> --channel my-channel --title Hello --body World --data \'{"key":"value"}\'', '<%= config.bin %> <%= command.id %> --channel my-channel --payload \'{"notification":{"title":"Hello","body":"World"},"data":{"key":"value"}}\'', "<%= config.bin %> <%= command.id %> --channel my-channel --payload ./notification.json", + "<%= config.bin %> <%= command.id %> --channel my-channel --title Hello --body World --message 'Hello from push'", + '<%= config.bin %> <%= command.id %> --channel my-channel --title Hello --body World --message \'{"event":"push","text":"Hello"}\'', '<%= config.bin %> <%= command.id %> --recipient \'{"transportType":"apns","deviceToken":"token123"}\' --title Hello --body World', "<%= config.bin %> <%= command.id %> --device-id device-123 --title Hello --body World --json", ]; @@ -45,6 +47,10 @@ export default class PushPublish extends AblyBaseCommand { description: "Target channel name (publishes push notification via the channel using extras.push; ignored if --device-id, --client-id, or --recipient is also provided)", }), + message: Flags.string({ + description: + "Realtime message data to include alongside the push notification (only applies when publishing via --channel)", + }), title: Flags.string({ description: "Notification title", }), @@ -91,6 +97,14 @@ export default class PushPublish extends AblyBaseCommand { const hasDirectRecipient = flags["device-id"] || flags["client-id"] || flags.recipient; + if (flags.message && !flags.channel) { + this.fail( + "--message can only be used with --channel (realtime message data is not applicable when publishing directly to a device or client)", + flags as BaseFlags, + "pushPublish", + ); + } + if (!hasDirectRecipient && !flags.channel) { this.fail( "A target is required: --device-id, --client-id, --recipient, or --channel", @@ -104,6 +118,12 @@ export default class PushPublish extends AblyBaseCommand { "--channel is ignored when --device-id, --client-id, or --recipient is provided.", flags as BaseFlags, ); + if (flags.message) { + this.logWarning( + "--message is ignored when --device-id, --client-id, or --recipient is provided.", + flags as BaseFlags, + ); + } } try { @@ -257,13 +277,28 @@ export default class PushPublish extends AblyBaseCommand { } } - await rest.channels - .get(channelName) - .publish({ extras: { push: payload } }); + const publishMessage: Record = { + extras: { push: payload }, + }; + if (flags.message) { + try { + publishMessage.data = JSON.parse(flags.message); + } catch { + publishMessage.data = flags.message; + } + } + + await rest.channels.get(channelName).publish(publishMessage); if (this.shouldOutputJson(flags)) { this.logJsonResult( - { notification: { published: true, channel: channelName } }, + { + notification: { + published: true, + channel: channelName, + ...(flags.message ? { messageData: publishMessage.data } : {}), + }, + }, flags, ); } diff --git a/test/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index 5a552d6e..885ee7b6 100644 --- a/test/unit/commands/push/publish.test.ts +++ b/test/unit/commands/push/publish.test.ts @@ -35,6 +35,7 @@ describe("push:publish command", () => { "--title", "--body", "--payload", + "--message", ]); describe("functionality", () => { @@ -195,9 +196,129 @@ describe("push:publish command", () => { expect(result.notification).toHaveProperty("published", true); expect(result.notification).toHaveProperty("channel", "my-channel"); }); + + it("should include string message data when publishing via channel", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("my-channel"); + + await runCommand( + [ + "push:publish", + "--channel", + "my-channel", + "--title", + "Hello", + "--message", + "hello-world", + "--force", + ], + import.meta.url, + ); + + expect(channel.publish).toHaveBeenCalledWith( + expect.objectContaining({ + data: "hello-world", + extras: { + push: expect.objectContaining({ + notification: expect.objectContaining({ title: "Hello" }), + }), + }, + }), + ); + }); + + it("should parse JSON message data when publishing via channel", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("my-channel"); + + await runCommand( + [ + "push:publish", + "--channel", + "my-channel", + "--title", + "Hello", + "--message", + '{"key":"val"}', + "--force", + ], + import.meta.url, + ); + + expect(channel.publish).toHaveBeenCalledWith( + expect.objectContaining({ + data: { key: "val" }, + extras: { + push: expect.objectContaining({ + notification: expect.objectContaining({ title: "Hello" }), + }), + }, + }), + ); + }); + + it("should ignore --message when direct recipient overrides --channel", async () => { + const mock = getMockAblyRest(); + + const { stdout, stderr } = await runCommand( + [ + "push:publish", + "--device-id", + "dev-1", + "--channel", + "my-channel", + "--message", + "hello", + "--title", + "Hi", + ], + import.meta.url, + ); + + expect(stdout + stderr).toContain("--message is ignored"); + expect(mock.push.admin.publish).toHaveBeenCalledWith( + { deviceId: "dev-1" }, + expect.anything(), + ); + }); + + it("should include messageData in JSON output when --message is used", async () => { + const { stdout } = await runCommand( + [ + "push:publish", + "--channel", + "my-channel", + "--title", + "Hi", + "--message", + "hello", + "--json", + "--force", + ], + import.meta.url, + ); + + const result = parseJsonOutput(stdout); + expect(result).toHaveProperty("notification"); + expect(result.notification).toHaveProperty("published", true); + expect(result.notification).toHaveProperty("channel", "my-channel"); + expect(result.notification).toHaveProperty("messageData", "hello"); + }); }); describe("error handling", () => { + it("should fail when --message is used without --channel", async () => { + const { error } = await runCommand( + ["push:publish", "--message", "hello", "--title", "Hi"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain( + "--message can only be used with --channel", + ); + }); + it("should handle API errors", async () => { const mock = getMockAblyRest(); mock.push.admin.publish.mockRejectedValue(new Error("Publish failed"));