diff --git a/apps/cli/src/__tests__/command-output/notifications.test.ts b/apps/cli/src/__tests__/command-output/notifications.test.ts new file mode 100644 index 0000000000..1a6f1248ec --- /dev/null +++ b/apps/cli/src/__tests__/command-output/notifications.test.ts @@ -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), + ]); + }); +}); diff --git a/apps/cli/src/__tests__/json-flag-enforcement.test.ts b/apps/cli/src/__tests__/json-flag-enforcement.test.ts index 9996250ba2..12962e7954 100644 --- a/apps/cli/src/__tests__/json-flag-enforcement.test.ts +++ b/apps/cli/src/__tests__/json-flag-enforcement.test.ts @@ -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(); @@ -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); diff --git a/apps/cli/src/commands/notifications.ts b/apps/cli/src/commands/notifications.ts new file mode 100644 index 0000000000..255d973a74 --- /dev/null +++ b/apps/cli/src/commands/notifications.ts @@ -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") + .requiredOption( + "--platform ", + `Device platform (${pushSubscriptionPlatformValues.join(" or ")})`, + ) + .requiredOption("--label