Skip to content
Draft
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
181 changes: 181 additions & 0 deletions apps/cli/src/__tests__/command-output/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, it, vi } from "vitest";
import {
collectLogLines,
collectLogPayloads,
runCommand,
setupCommandOutputTestEnvironment,
stubServerApi,
type CommandRegistrar,
} from "../helpers/command-output-harness.js";
import { registerNotificationCommands } from "../../commands/notifications.js";

const subscription = {
id: "push_abc123",
expoPushToken: "ExponentPushToken[abc]",
platform: "ios",
deviceLabel: "Sawyer's iPhone",
createdAt: 1_000,
lastSeenAt: Date.now(),
};

describe("bb notifications push-subscriptions commands", () => {
setupCommandOutputTestEnvironment();

const register: CommandRegistrar = (program) =>
registerNotificationCommands(program, () => "http://server");

it("lists registered devices as a table and as JSON", async () => {
stubServerApi({
"v1.notifications.push-subscriptions.$get": vi.fn(async () => ({
subscriptions: [subscription],
})),
});

await runCommand(["notifications", "push-subscriptions", "list"], register);
const lines = collectLogLines(vi.mocked(console.log)).join("\n");
expect(lines).toContain("ID");
expect(lines).toContain("push_abc123");
expect(lines).toContain("Sawyer's iPhone");
expect(lines).toContain("ios");
expect(lines).toContain("just now");

vi.mocked(console.log).mockClear();
await runCommand(
["notifications", "push-subscriptions", "list", "--json"],
register,
);
expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
JSON.stringify([subscription], null, 2),
]);
});

it("prints a hint when no devices are registered", async () => {
stubServerApi({
"v1.notifications.push-subscriptions.$get": vi.fn(async () => ({
subscriptions: [],
})),
});

await runCommand(["notifications", "push-subscriptions", "list"], register);
expect(collectLogLines(vi.mocked(console.log))).toEqual([
"No push devices registered",
]);
});

it("registers a device and reports created versus refreshed", async () => {
let calls = 0;
const post = vi.fn(async ({ json }) => {
calls += 1;
return new Response(
JSON.stringify({ ...subscription, ...json, lastSeenAt: 5 }),
{
status: calls === 1 ? 201 : 200,
headers: { "Content-Type": "application/json" },
},
);
});
stubServerApi({ "v1.notifications.push-subscriptions.$post": post });

await runCommand(
[
"notifications",
"push-subscriptions",
"add",
"--token",
"ExponentPushToken[abc]",
"--platform",
"ios",
"--label",
"Sawyer's iPhone",
],
register,
);
await runCommand(
[
"notifications",
"push-subscriptions",
"add",
"--token",
"ExponentPushToken[abc]",
"--platform",
"ios",
"--label",
"Sawyer's iPhone",
"--json",
],
register,
);

expect(post).toHaveBeenCalledTimes(2);
expect(post).toHaveBeenNthCalledWith(1, {
json: {
expoPushToken: "ExponentPushToken[abc]",
platform: "ios",
deviceLabel: "Sawyer's iPhone",
},
});
expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
"Registered push device Sawyer's iPhone (push_abc123)",
JSON.stringify(
{
created: false,
subscription: { ...subscription, lastSeenAt: 5 },
},
null,
2,
),
]);
});

it("rejects unknown platforms before calling the server", async () => {
const post = vi.fn();
stubServerApi({ "v1.notifications.push-subscriptions.$post": post });

await expect(
runCommand(
[
"notifications",
"push-subscriptions",
"add",
"--token",
"ExponentPushToken[abc]",
"--platform",
"web",
"--label",
"Browser",
],
register,
),
).rejects.toThrow("process.exit:1");
expect(post).not.toHaveBeenCalled();
expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain(
"Invalid platform 'web'",
);
});

it("removes a device by id", async () => {
const del = vi.fn(async () => ({ ok: true }));
stubServerApi({ "v1.notifications.push-subscriptions.:id.$delete": del });

await runCommand(
["notifications", "push-subscriptions", "remove", "push_abc123"],
register,
);
await runCommand(
[
"notifications",
"push-subscriptions",
"remove",
"push_abc123",
"--json",
],
register,
);

expect(del).toHaveBeenCalledWith({ param: { id: "push_abc123" } });
expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
"Removed push device push_abc123",
JSON.stringify({ id: "push_abc123", ok: true }, null, 2),
]);
});
});
2 changes: 2 additions & 0 deletions apps/cli/src/__tests__/json-flag-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { registerProjectCommands } from "../commands/project.js";
import { registerProviderCommands } from "../commands/provider.js";
import { registerManagerCommands } from "../commands/manager.js";
import { registerMachineCommands } from "../commands/machine.js";
import { registerNotificationCommands } from "../commands/notifications.js";
import { registerThreadCommands } from "../commands/thread/index.js";
// Commands intentionally excluded from --json requirement
const EXCLUDED_COMMANDS = new Set<string>();
Expand Down Expand Up @@ -37,6 +38,7 @@ describe("CLI --json flag enforcement", () => {
registerProviderCommands(program, getUrl);
registerManagerCommands(program, getUrl);
registerMachineCommands(program, getUrl);
registerNotificationCommands(program, getUrl);
registerThreadCommands(program, getUrl);

const commands = collectLeafCommands(program);
Expand Down
136 changes: 136 additions & 0 deletions apps/cli/src/commands/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Command } from "commander";
import {
pushSubscriptionPlatformSchema,
pushSubscriptionPlatformValues,
type PushSubscription,
type PushSubscriptionPlatform,
} from "@bb/server-contract";
import { action } from "../action.js";
import { createCliBbSdk } from "../client.js";
import { renderBorderlessTable } from "../table.js";
import { formatMachineLastSeen } from "./machine.js";
import { outputJson, type JsonOutputOptions } from "./helpers.js";

// Commander enforces the three required options before the action runs.
interface PushSubscriptionAddCommandOptions extends JsonOutputOptions {
token: string;
platform: string;
label: string;
}

function parsePlatform(value: string): PushSubscriptionPlatform {
const parsed = pushSubscriptionPlatformSchema.safeParse(value);
if (!parsed.success) {
throw new Error(
`Invalid platform '${value}'. Expected one of: ${pushSubscriptionPlatformValues.join(", ")}.`,
);
}
return parsed.data;
}

function printPushSubscriptionTable(
subscriptions: readonly PushSubscription[],
): void {
const now = Date.now();
const rows = subscriptions.map((subscription) => [
subscription.id,
subscription.deviceLabel,
subscription.platform,
formatMachineLastSeen(subscription.lastSeenAt, now),
subscription.expoPushToken,
]);
const widths = [
Math.max(2, ...rows.map((row) => row[0].length)),
Math.max(6, ...rows.map((row) => row[1].length)),
Math.max(8, ...rows.map((row) => row[2].length)),
Math.max(9, ...rows.map((row) => row[3].length)),
Math.max(5, ...rows.map((row) => row[4].length)),
];
console.log("");
console.log(
renderBorderlessTable(
{
head: ["ID", "Device", "Platform", "Last seen", "Token"],
colWidths: widths,
trimTrailingWhitespace: true,
},
rows,
),
);
console.log("");
}

export function registerNotificationCommands(
program: Command,
getUrl: () => string,
): void {
const notifications = program
.command("notifications")
.description("Manage push notifications for bb mobile devices");

const pushSubscriptions = notifications
.command("push-subscriptions")
.description(
"Devices registered for Expo push notifications (pending interactions, finished turns, errors)",
);

pushSubscriptions
.command("list")
.description("List registered push devices")
.option("--json", "Print machine-readable JSON output")
.action(
action(async (opts: JsonOutputOptions) => {
const subscriptions =
await createCliBbSdk(getUrl()).notifications.pushSubscriptions.list();
if (outputJson(opts, subscriptions)) return;
if (subscriptions.length === 0) {
console.log("No push devices registered");
return;
}
printPushSubscriptionTable(subscriptions);
}),
);

pushSubscriptions
.command("add")
.description(
"Register an Expo push token, or refresh an existing registration",
)
.requiredOption("--token <expo-push-token>", "Expo push token")
.requiredOption(
"--platform <platform>",
`Device platform (${pushSubscriptionPlatformValues.join(" or ")})`,
)
.requiredOption("--label <label>", "Human-readable device name")
.option("--json", "Print machine-readable JSON output")
.action(
action(async (opts: PushSubscriptionAddCommandOptions) => {
const result = await createCliBbSdk(
getUrl(),
).notifications.pushSubscriptions.add({
expoPushToken: opts.token,
platform: parsePlatform(opts.platform),
deviceLabel: opts.label,
});
if (outputJson(opts, result)) return;
const verb = result.created ? "Registered" : "Refreshed";
console.log(
`${verb} push device ${result.subscription.deviceLabel} (${result.subscription.id})`,
);
}),
);

pushSubscriptions
.command("remove <id>")
.description("Remove a registered push device")
.option("--json", "Print machine-readable JSON output")
.action(
action(async (id: string, opts: JsonOutputOptions) => {
const result = await createCliBbSdk(
getUrl(),
).notifications.pushSubscriptions.remove({ id });
if (outputJson(opts, { id, ...result })) return;
console.log(`Removed push device ${id}`);
}),
);
}
2 changes: 2 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerGuideCommand } from "./commands/guide.js";
import { registerManagerCommands } from "./commands/manager.js";
import { registerMarketplaceCommands } from "./commands/marketplace.js";
import { registerMachineCommands } from "./commands/machine.js";
import { registerNotificationCommands } from "./commands/notifications.js";
import { registerProjectCommands } from "./commands/project.js";
import { registerPluginCommands } from "./commands/plugin.js";
import { registerProviderCommands } from "./commands/provider.js";
Expand Down Expand Up @@ -90,6 +91,7 @@ registerProjectCommands(program, getUrl);
registerProviderCommands(program, getUrl);
registerManagerCommands(program, getUrl);
registerMachineCommands(program, getUrl);
registerNotificationCommands(program, getUrl);
registerUpdatesCommands(program, getUrl);
registerTerminalCommands(program, getUrl);
registerThreadCommands(program, getUrl);
Expand Down
Loading
Loading