From 72a01c58af1e0536e13b5261493ca63d55584163 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Thu, 16 Apr 2026 21:56:28 +0200 Subject: [PATCH] feat(push): support realtime message data in batch-publish channel items Co-Authored-By: Claude Sonnet 4.6 --- src/commands/push/batch-publish.ts | 64 +++++- test/unit/commands/push/batch-publish.test.ts | 200 ++++++++++++++++++ 2 files changed, 257 insertions(+), 7 deletions(-) diff --git a/src/commands/push/batch-publish.ts b/src/commands/push/batch-publish.ts index db0e2a35e..5d9fe7d77 100644 --- a/src/commands/push/batch-publish.ts +++ b/src/commands/push/batch-publish.ts @@ -7,6 +7,7 @@ import { CommandError } from "../../errors/command-error.js"; import { forceFlag, productApiFlags } from "../../flags.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import { BaseFlags } from "../../types/cli.js"; +import { prepareMessageFromInput } from "../../utils/message.js"; import { formatCountLabel, formatResource } from "../../utils/output.js"; interface BatchResponseItem { @@ -44,6 +45,11 @@ export default class PushBatchPublish extends AblyBaseCommand { command: '<%= config.bin %> <%= command.id %> \'[{"channels":["my-channel"],"payload":{"notification":{"title":"Hello","body":"World"}}}]\' --force', }, + { + description: "Publish to a channel with an accompanying realtime message", + command: + '<%= config.bin %> <%= command.id %> \'[{"channels":["my-channel"],"payload":{"notification":{"title":"Hello","body":"World"}},"message":"Hello from push"}]\' --force', + }, { description: "Publish to multiple channels in one batch item", command: @@ -73,7 +79,7 @@ export default class PushBatchPublish extends AblyBaseCommand { static override args = { payload: Args.string({ description: - 'Batch payload as JSON array, filepath, or - for stdin. Each item must have either a "recipient" or "channels" key. Items with "channels" are routed via channel batch publish with the payload wrapped in extras.push', + 'Batch payload as JSON array, filepath, or - for stdin. Each item must have either a "recipient" or "channels" key. Items with "channels" are routed via channel batch publish with the payload wrapped in extras.push. Channel items may include an optional "message" field with realtime message data (string or JSON) to publish alongside the push notification.', }), }; @@ -246,7 +252,18 @@ export default class PushBatchPublish extends AblyBaseCommand { "/push/batch/publish", 2, null, - recipientItems.map(({ entry }) => entry), + recipientItems.map(({ entry, originalIndex }) => { + if (entry.message !== undefined) { + this.logWarning( + `Item at index ${originalIndex}: "message" is not applicable for recipient-based push and will be ignored`, + flags as BaseFlags, + ); + const sanitized = { ...entry }; + delete sanitized.message; + return sanitized; + } + return entry; + }), ); if (response.statusCode < 200 || response.statusCode >= 300) { @@ -292,12 +309,45 @@ export default class PushBatchPublish extends AblyBaseCommand { // Channel-based push: route to /messages with extras.push if (channelItems.length > 0) { - const channelBatchSpecs = channelItems.map(({ entry }) => ({ - channels: entry.channels, - messages: { - extras: { push: entry.payload }, + const channelBatchSpecs = channelItems.map( + ({ entry, originalIndex }) => { + let extras: Record = { push: entry.payload }; + const messageObj: Record = {}; + + if (entry.message !== undefined) { + const parsed = prepareMessageFromInput( + JSON.stringify(entry.message), + {}, + ); + const parsedExtras = parsed.extras as + | Record + | undefined; + + if ( + parsedExtras && + Object.prototype.hasOwnProperty.call(parsedExtras, "push") + ) { + this.fail( + `Item at index ${originalIndex}: message must not include extras.push; use the payload field to specify push content`, + flags as BaseFlags, + "pushBatchPublish", + ); + } + + if (parsed.name !== undefined) messageObj.name = parsed.name; + if (parsed.data !== undefined) messageObj.data = parsed.data; + if (parsedExtras) { + extras = { ...parsedExtras, push: entry.payload }; + } + } + + messageObj.extras = extras; + return { + channels: entry.channels, + messages: messageObj, + }; }, - })); + ); if (!flags.force && this.shouldOutputJson(flags)) { this.fail( diff --git a/test/unit/commands/push/batch-publish.test.ts b/test/unit/commands/push/batch-publish.test.ts index 6cd344fc0..a67e62ead 100644 --- a/test/unit/commands/push/batch-publish.test.ts +++ b/test/unit/commands/push/batch-publish.test.ts @@ -61,6 +61,206 @@ describe("push:batch-publish command", () => { ); }); + it("should include string message data when channel item has a message field", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + channels: ["my-channel"], + payload: { notification: { title: "Hello" } }, + message: "Hello from push", + }, + ]); + + await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledWith("post", "/messages", 2, null, [ + expect.objectContaining({ + channels: ["my-channel"], + messages: expect.objectContaining({ + data: "Hello from push", + extras: expect.objectContaining({ push: expect.anything() }), + }), + }), + ]); + }); + + it("should extract name and data from message object — case 1 (both present, others ignored)", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + channels: ["my-channel"], + payload: { notification: { title: "Hello" } }, + message: { name: "smth", data: "hey there!", ignored: true }, + }, + ]); + + await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledWith("post", "/messages", 2, null, [ + expect.objectContaining({ + messages: expect.objectContaining({ + name: "smth", + data: "hey there!", + extras: expect.objectContaining({ push: expect.anything() }), + }), + }), + ]); + }); + + it("should put remaining fields into data when name present but data absent — case 2", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + channels: ["my-channel"], + payload: { notification: { title: "Hello" } }, + message: { name: "evt", foo: "bar" }, + }, + ]); + + await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledWith("post", "/messages", 2, null, [ + expect.objectContaining({ + messages: expect.objectContaining({ + name: "evt", + data: { foo: "bar" }, + }), + }), + ]); + }); + + it("should preserve non-push extras from message alongside extras.push", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + channels: ["my-channel"], + payload: { notification: { title: "Hello" } }, + message: { data: "hi", extras: { foo: "bar" } }, + }, + ]); + + await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledWith("post", "/messages", 2, null, [ + expect.objectContaining({ + messages: expect.objectContaining({ + data: "hi", + extras: { foo: "bar", push: expect.anything() }, + }), + }), + ]); + }); + + it("should fail when message.extras.push is provided in a channel item", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + channels: ["my-channel"], + payload: { notification: { title: "Hello" } }, + message: { + name: "smth", + data: "hey there!", + extras: { push: { notification: { title: "Override" } } }, + }, + }, + ]); + + const { error } = await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("message must not include extras.push"); + expect( + mock.request.mock.calls.some( + ([method, path]) => method === "post" && path === "/messages", + ), + ).toBe(false); + }); + + it("should set plain object without reserved keys as data", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + channels: ["my-channel"], + payload: { notification: { title: "Hello" } }, + message: { foo: "bar" }, + }, + ]); + + await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledWith("post", "/messages", 2, null, [ + expect.objectContaining({ + messages: expect.objectContaining({ + data: { foo: "bar" }, + }), + }), + ]); + }); + + it("should strip message field from recipient items and warn", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + recipient: { deviceId: "dev-1" }, + payload: { notification: { title: "Hello" } }, + message: "should be ignored", + }, + ]); + + const { stderr } = await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(stderr).toContain('"message" is not applicable'); + expect(mock.request).toHaveBeenCalledWith( + "post", + "/push/batch/publish", + 2, + null, + [expect.not.objectContaining({ message: expect.anything() })], + ); + }); + + it("should omit data field when channel item has no message field", async () => { + const mock = getMockAblyRest(); + const payload = JSON.stringify([ + { + channels: ["my-channel"], + payload: { notification: { title: "Hello" } }, + }, + ]); + + await runCommand( + ["push:batch-publish", payload, "--force"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledWith("post", "/messages", 2, null, [ + expect.objectContaining({ + messages: expect.not.objectContaining({ data: expect.anything() }), + }), + ]); + }); + it("should output JSON when requested", async () => { getMockAblyRest(); const payload =