Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions src/commands/push/batch-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.',
}),
};

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, unknown> = { push: entry.payload };
const messageObj: Record<string, unknown> = {};

if (entry.message !== undefined) {
const parsed = prepareMessageFromInput(
JSON.stringify(entry.message),
{},
);
const parsedExtras = parsed.extras as
| Record<string, unknown>
| 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(
Expand Down
200 changes: 200 additions & 0 deletions test/unit/commands/push/batch-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading