From a8e2da885d102e307874e9b1132ecb3468e9fd5d Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:40:01 +0800 Subject: [PATCH] fix(spectrum): surface exhausted shared-line capacity --- README.md | 3 +- src/commands/projects.ts | 116 ++++-- src/commands/spectrum/platforms.ts | 41 ++- src/commands/spectrum/users.ts | 81 ++++- src/lib/types.ts | 40 ++- tests/_setup.ts | 3 + tests/contract/projects.contract.test.ts | 432 +++++++++++++++++++++++ tests/helpers/mock-server.ts | 186 +++++++++- 8 files changed, 841 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 2ef693d..b27163c 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,8 @@ photon ├── projects │ ├── ls list projects │ ├── show [id] project detail -│ ├── create [--name --location --spectrum] new project +│ ├── create [--name --location --platforms ] +│ │ new project; no platform when omitted │ ├── update [id] [...] rename / toggle flags │ ├── delete [id] [-y] permanent delete │ ├── regenerate-secret [id] [-y] rotate Spectrum secret diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 150cb42..4f7f6a0 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -20,12 +20,74 @@ import { SessionExpiredError } from "~/lib/errors.ts"; import { confirmDestructive } from "~/lib/interactive.ts"; import { c, die, formatApiError, printJson, printTable } from "~/lib/output.ts"; import { requireArray } from "~/lib/shape.ts"; +import type { + ProjectCreateResult, + ProjectCreateWarning, + ProjectCreateWarningCode, +} from "~/lib/types.ts"; import { isInteractive } from "~/lib/tty.ts"; /** Platforms accepted by `projects create` (mirrors the API's create body). */ const PLATFORMS = ["imessage", "whatsapp_business", "voice"] as const; type Platform = (typeof PLATFORMS)[number]; +const PROJECT_CREATE_WARNINGS = { + owner_phone_missing: { + code: "owner_phone_missing", + message: + "Your project was created without a connected phone. Add a phone number to your Photon account or connect a dedicated line.", + }, + shared_line_unavailable: { + code: "shared_line_unavailable", + message: + "We couldn't connect your phone to a shared iMessage line. You can add another phone or connect a dedicated line.", + }, + owner_enrollment_failed: { + code: "owner_enrollment_failed", + message: + "We couldn't connect your phone to a shared iMessage line. Try again with another phone or connect a dedicated line.", + }, +} as const satisfies Record; + +const OWNER_STATUS_WARNING_CODES = { + skipped_no_phone: "owner_phone_missing", + skipped_pool_exhausted: "shared_line_unavailable", + failed: "owner_enrollment_failed", +} as const satisfies Record; + +type OwnerWarningStatus = keyof typeof OWNER_STATUS_WARNING_CODES; + +function isOwnerWarningStatus(value: unknown): value is OwnerWarningStatus { + return typeof value === "string" && value in OWNER_STATUS_WARNING_CODES; +} + +function isProjectCreateWarningCode( + value: unknown +): value is ProjectCreateWarningCode { + return ( + typeof value === "string" && value in PROJECT_CREATE_WARNINGS + ); +} + +function readProjectCreateWarning(result: { + ownerStatus?: unknown; + warning?: unknown; +}): ProjectCreateWarning | undefined { + if (result.warning && typeof result.warning === "object") { + const warning = result.warning as { code?: unknown; message?: unknown }; + if ( + isProjectCreateWarningCode(warning.code) && + typeof warning.message === "string" + ) { + return PROJECT_CREATE_WARNINGS[warning.code]; + } + } + if (!isOwnerWarningStatus(result.ownerStatus)) return undefined; + return PROJECT_CREATE_WARNINGS[ + OWNER_STATUS_WARNING_CODES[result.ownerStatus] + ]; +} + export function registerProjectsCommand(program: Command): void { const projects = program .command("projects") @@ -216,7 +278,10 @@ function registerCreateCommand(projects: Command): void { .description("create a new project") .option("-n, --name ", "project name") .option("-l, --location ", 'location (default: "United States")') - .option("--platforms ", `comma-separated platforms (${PLATFORMS.join(", ")})`) + .option( + "--platforms ", + `comma-separated platforms (${PLATFORMS.join(", ")}); omit to enable none` + ) .option("--template", "use as template") .option("--observability", "enable observability") .option("--api-host ", "API host URL (defaults to PHOTON_API_HOST or built-in production)") @@ -241,7 +306,10 @@ function registerCreateCommand(projects: Command): void { if (error) { die(`Failed to create project: ${formatApiError(error)}`); } - const result = data as { success?: true; id?: string; error?: string }; + if (!data) { + die("Server did not return a project result."); + } + const result = data as unknown as ProjectCreateResult; if (result.error) { die(result.error); } @@ -249,8 +317,14 @@ function registerCreateCommand(projects: Command): void { die("Server did not return a project id."); } + const warning = readProjectCreateWarning(result); if (opts.json) { - printJson({ id: result.id, name: filled.name, env: env.name }); + printJson({ + id: result.id, + name: filled.name, + env: env.name, + ...(warning ? { warning } : {}), + }); return; } @@ -270,6 +344,9 @@ function registerCreateCommand(projects: Command): void { ) ); } + if (warning) { + console.error(c.warn(warning.message)); + } console.log( c.dim(` To make this the active project: export PHOTON_PROJECT_ID='${result.id}'`) ); @@ -304,6 +381,9 @@ function parsePlatforms(value: string): Platform[] { } async function fillCreateOpts(opts: CreateOpts): Promise { + const platforms = + opts.platforms !== undefined ? parsePlatforms(opts.platforms) : []; + // Non-interactive path: name is required; defaults fill the rest. if (!isInteractive()) { if (!opts.name?.trim()) { @@ -314,7 +394,7 @@ async function fillCreateOpts(opts: CreateOpts): Promise { return { name: opts.name.trim(), location: opts.location ?? "United States", - platforms: opts.platforms !== undefined ? parsePlatforms(opts.platforms) : [], + platforms, template: opts.template ?? false, observability: opts.observability ?? false, }; @@ -348,16 +428,6 @@ async function fillCreateOpts(opts: CreateOpts): Promise { location = answer || "United States"; } - const platforms = - opts.platforms !== undefined - ? parsePlatforms(opts.platforms) - : parsePlatforms( - await promptText( - `Platforms (comma-separated: ${PLATFORMS.join(", ")})`, - undefined, - true - ) - ); const template = opts.template ?? (await promptBool("Use as template?", false)); const observability = opts.observability ?? (await promptBool("Enable observability?", false)); @@ -366,24 +436,6 @@ async function fillCreateOpts(opts: CreateOpts): Promise { return { name, location, platforms, template, observability }; } -/** - * Free-text prompt. When `optional`, an empty answer is allowed and - * returns "". Aborts on cancel. - */ -async function promptText( - message: string, - preset?: string, - optional = false -): Promise { - if (preset !== undefined) return preset; - const answer = await text({ - message, - placeholder: optional ? "(skip)" : undefined, - }); - if (isCancel(answer)) die("Aborted."); - return answer ?? ""; -} - async function promptBool(message: string, initial: boolean): Promise { const answer = await clackConfirm({ message, initialValue: initial }); if (isCancel(answer)) die("Aborted."); diff --git a/src/commands/spectrum/platforms.ts b/src/commands/spectrum/platforms.ts index 4dc5bf8..114f70e 100644 --- a/src/commands/spectrum/platforms.ts +++ b/src/commands/spectrum/platforms.ts @@ -4,6 +4,26 @@ import { resolveProject } from "~/lib/api-context.ts"; import { SessionExpiredError } from "~/lib/errors.ts"; import { c, die, formatApiError, printJson, printTable } from "~/lib/output.ts"; import { requireBooleanRecord } from "~/lib/shape.ts"; +import type { + PlatformToggleResult, + PlatformToggleWarning, +} from "~/lib/types.ts"; + +const IMESSAGE_CONNECTION_MISSING_WARNING: PlatformToggleWarning = { + code: "imessage_connection_missing", + message: + "iMessage was enabled without a connected phone. Add another phone or connect a dedicated line.", +}; + +function readPlatformToggleWarning( + value: unknown +): PlatformToggleWarning | undefined { + if (!(value && typeof value === "object")) return undefined; + const warning = value as { code?: unknown }; + return warning.code === "imessage_connection_missing" + ? IMESSAGE_CONNECTION_MISSING_WARNING + : undefined; +} export function registerSpectrumPlatforms(spectrum: Command): void { const platforms = spectrum @@ -91,11 +111,7 @@ async function togglePlatform( .platforms.toggle.post({ platformId: name, enabled }); if (status === 401) throw new SessionExpiredError(resolved.name); if (error) die(`Failed to ${enabled ? "enable" : "disable"} ${name}: ${formatApiError(error)}`); - const result = data as { - success?: true; - platforms?: Record; - error?: string; - }; + const result = data as PlatformToggleResult; if (result.error) { die(result.error, { hint: @@ -105,6 +121,19 @@ async function togglePlatform( }); } - if (opts.json) return printJson(result.platforms ?? {}); + const warning = + name === "imessage" && enabled + ? readPlatformToggleWarning(result.warning) + : undefined; + if (opts.json) { + return printJson( + warning + ? { platforms: result.platforms ?? {}, warning } + : (result.platforms ?? {}), + ); + } console.log(c.success(`${enabled ? "Enabled" : "Disabled"} ${c.bold(name)}`)); + if (warning) { + console.error(c.warn(warning.message)); + } } diff --git a/src/commands/spectrum/users.ts b/src/commands/spectrum/users.ts index 446474c..d4a85ca 100644 --- a/src/commands/spectrum/users.ts +++ b/src/commands/spectrum/users.ts @@ -6,6 +6,10 @@ import { SessionExpiredError } from "~/lib/errors.ts"; import { confirmDestructive } from "~/lib/interactive.ts"; import { c, die, formatApiError, printJson, printTable } from "~/lib/output.ts"; import { requireArrayField } from "~/lib/shape.ts"; +import type { + SpectrumUserAddFailure, + SpectrumUserAddFailureCode, +} from "~/lib/types.ts"; import { isInteractive } from "~/lib/tty.ts"; export function registerSpectrumUsers(spectrum: Command): void { @@ -92,9 +96,27 @@ export function registerSpectrumUsers(spectrum: Command): void { sendInvite: opts.invite ?? false, }); if (status === 401) throw new SessionExpiredError(resolved.name); - if (error) die(`Failed to add user: ${formatApiError(error)}`); - const result = data as { success?: true; user?: SpectrumUser; error?: string }; - if (result.error) die(result.error); + if (error) failSpectrumUserAdd(error, opts.json ?? false); + if (!data) { + failSpectrumUserAdd( + "Server did not return a Spectrum user result.", + opts.json ?? false, + ); + } + const result = data as { + success?: true; + user?: SpectrumUser; + error?: string; + }; + if (result.error) { + failSpectrumUserAdd( + { + code: "shared_user_create_failed", + message: result.error, + }, + opts.json ?? false, + ); + } if (opts.json) return printJson(result.user ?? {}); const u = result.user; @@ -151,6 +173,59 @@ interface SpectrumUser { phoneNumber?: string | null; } +function isSpectrumUserAddFailureCode( + value: unknown +): value is SpectrumUserAddFailureCode { + return ( + value === "imessage_not_enabled" || + value === "shared_line_unavailable" || + value === "shared_user_create_failed" || + value === "shared_user_limit_reached" + ); +} + +function findStructuredFailure(error: unknown): SpectrumUserAddFailure | null { + const queue: unknown[] = [error]; + const seen = new Set(); + while (queue.length > 0) { + const value = queue.shift(); + if (!(value && typeof value === "object") || seen.has(value)) continue; + seen.add(value); + const record = value as Record; + if ( + isSpectrumUserAddFailureCode(record.code) && + typeof record.message === "string" + ) { + return { code: record.code, message: record.message }; + } + for (const key of ["value", "message", "error", "cause"]) { + if (record[key] && typeof record[key] === "object") { + queue.push(record[key]); + } + } + } + return null; +} + +const SHARED_LINE_UNAVAILABLE_MESSAGE = + "This phone couldn't be connected to a shared iMessage line. Try another phone or connect a dedicated line."; + +function failSpectrumUserAdd(error: unknown, json: boolean): never { + const parsedFailure = findStructuredFailure(error) ?? { + code: "shared_user_create_failed", + message: formatApiError(error), + }; + const failure = + parsedFailure.code === "shared_line_unavailable" + ? { ...parsedFailure, message: SHARED_LINE_UNAVAILABLE_MESSAGE } + : parsedFailure; + if (json) { + printJson({ error: failure }); + process.exit(1); + } + die(`Failed to add user: ${failure.message}`); +} + interface FilledAdd { firstName: string; lastName: string; diff --git a/src/lib/types.ts b/src/lib/types.ts index 179f417..f393001 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,4 +9,42 @@ * in command logic. */ -export {}; +export type ProjectCreateWarningCode = + | "owner_enrollment_failed" + | "owner_phone_missing" + | "shared_line_unavailable"; + +export interface ProjectCreateWarning { + code: ProjectCreateWarningCode; + message: string; +} + +export interface ProjectCreateResult { + error?: string; + id?: string; + ownerStatus?: unknown; + warning?: unknown; +} + +export interface PlatformToggleWarning { + code: "imessage_connection_missing"; + message: string; +} + +export interface PlatformToggleResult { + error?: string; + platforms?: Record; + success?: true; + warning?: unknown; +} + +export type SpectrumUserAddFailureCode = + | "imessage_not_enabled" + | "shared_line_unavailable" + | "shared_user_create_failed" + | "shared_user_limit_reached"; + +export interface SpectrumUserAddFailure { + code: SpectrumUserAddFailureCode; + message: string; +} diff --git a/tests/_setup.ts b/tests/_setup.ts index 9bd5196..e433d1d 100644 --- a/tests/_setup.ts +++ b/tests/_setup.ts @@ -9,6 +9,9 @@ process.env.TZ = "UTC"; process.env.LC_ALL = "C"; process.env.COLUMNS = "120"; process.env.FORCE_TTY = "0"; +// `bun test` can be launched from a real terminal. Mark the test process as +// CI so CLI commands never open prompts that can outlive a timed-out test. +process.env.CI = "1"; // Deterministic timestamps when PHOTON_TEST_NOW is set. if (process.env.PHOTON_TEST_NOW) { diff --git a/tests/contract/projects.contract.test.ts b/tests/contract/projects.contract.test.ts index cd73990..3395951 100644 --- a/tests/contract/projects.contract.test.ts +++ b/tests/contract/projects.contract.test.ts @@ -7,8 +7,13 @@ import { test, } from "bun:test"; import { + getMockPlatformToggleRequests, getMockProjectCreateRequests, + getMockProjectDeleteRequests, resetMockState, + setMockPlatformToggleWarning, + setMockProjectCreateOwnerStatus, + setMockSpectrumUserAddFailure, startMockServer, stopMockServer, } from "../helpers/mock-server.ts"; @@ -148,6 +153,433 @@ describe("photon projects list", () => { }); }); +describe("photon projects create", () => { + test("creates the project and warns when owner enrollment is exhausted", async () => { + resetMockState(); + setMockProjectCreateOwnerStatus("skipped_pool_exhausted"); + + const { stdout, stderr, exitCode } = await runCommand( + [ + "projects", + "create", + "--name", + "Quota test", + "--platforms", + "imessage", + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Created Quota test"); + expect(stderr).toContain( + "We couldn't connect your phone to a shared iMessage line", + ); + expect(getMockProjectDeleteRequests()).toEqual([]); + }); + + test("create --json includes the non-blocking warning", async () => { + resetMockState(); + setMockProjectCreateOwnerStatus("skipped_pool_exhausted"); + + const { stdout, stderr, exitCode } = await runCommand( + [ + "projects", + "create", + "--name", + "Quota test", + "--platforms", + "imessage", + "--json", + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toMatchObject({ + id: "00000000-0000-4000-a000-000000000001", + name: "Quota test", + warning: { + code: "shared_line_unavailable", + message: + "We couldn't connect your phone to a shared iMessage line. You can add another phone or connect a dedicated line.", + }, + }); + expect(getMockProjectDeleteRequests()).toEqual([]); + }); + + test("still warns when deletion does not restore shared capacity", async () => { + resetMockState(); + setMockProjectCreateOwnerStatus("skipped_pool_exhausted"); + const projectId = "00000000-0000-4000-a000-000000000001"; + const env = { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }; + + const deletion = await runCommand( + ["projects", "delete", projectId, "--yes"], + { env }, + ); + expect(deletion.exitCode).toBe(0); + expect(getMockProjectDeleteRequests()).toEqual([projectId]); + + const creation = await runCommand( + [ + "projects", + "create", + "--name", + "After deletion", + "--platforms", + "imessage", + ], + { env }, + ); + + expect(creation.exitCode).toBe(0); + expect(creation.stdout).toContain("Created After deletion"); + expect(creation.stderr).toContain( + "We couldn't connect your phone to a shared iMessage line", + ); + }); + + test("omitting --platforms creates a platformless project without an iMessage warning", async () => { + resetMockState(); + setMockProjectCreateOwnerStatus("skipped_pool_exhausted"); + + const { stdout, stderr, exitCode } = await runCommand( + ["projects", "create", "--name", "Platformless"], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Created Platformless"); + expect(stderr).toBe(""); + expect(getMockProjectCreateRequests().at(-1)?.platforms).toEqual([]); + }); + + test("omitting --platforms stays platformless in an interactive terminal", async () => { + resetMockState(); + const originalCI = process.env.CI; + const stdoutDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + "isTTY", + ); + const stdinDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY", + ); + delete process.env.CI; + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + + try { + const { exitCode } = await runCommand( + [ + "projects", + "create", + "--name", + "Interactive platformless", + "--location", + "United States", + "--template", + "--observability", + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(getMockProjectCreateRequests().at(-1)?.platforms).toEqual([]); + } finally { + if (originalCI === undefined) delete process.env.CI; + else process.env.CI = originalCI; + if (stdoutDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor); + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } + if (stdinDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + } + }); +}); + +describe("photon spectrum platforms enable", () => { + const projectId = "00000000-0000-4000-a000-000000000001"; + + test("succeeds and warns when iMessage has no connected phone", async () => { + resetMockState(); + setMockPlatformToggleWarning(true); + + const { stdout, stderr, exitCode } = await runCommand( + [ + "spectrum", + "platforms", + "enable", + "imessage", + "--project", + projectId, + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Enabled imessage"); + expect(stderr).toContain( + "iMessage was enabled without a connected phone. Add another phone or connect a dedicated line.", + ); + expect(getMockPlatformToggleRequests()).toEqual([ + { projectId, platformId: "imessage", enabled: true }, + ]); + }); + + test("--json keeps the successful platform state and warning", async () => { + resetMockState(); + setMockPlatformToggleWarning(true); + + const { stdout, stderr, exitCode } = await runCommand( + [ + "spectrum", + "platforms", + "enable", + "imessage", + "--project", + projectId, + "--json", + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + platforms: { imessage: true }, + warning: { + code: "imessage_connection_missing", + message: + "iMessage was enabled without a connected phone. Add another phone or connect a dedicated line.", + }, + }); + }); + + test("does not show an iMessage warning for another platform", async () => { + resetMockState(); + setMockPlatformToggleWarning(true); + + const { stdout, stderr, exitCode } = await runCommand( + [ + "spectrum", + "platforms", + "enable", + "whatsapp", + "--project", + projectId, + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Enabled whatsapp"); + expect(stderr).toBe(""); + }); + + test("does not show an iMessage warning when disabling iMessage", async () => { + resetMockState(); + setMockPlatformToggleWarning(true); + + const { stdout, stderr, exitCode } = await runCommand( + [ + "spectrum", + "platforms", + "disable", + "imessage", + "--project", + projectId, + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Disabled imessage"); + expect(stderr).toBe(""); + }); + + test("preserves the raw platform map in JSON when there is no warning", async () => { + resetMockState(); + + const { stdout, stderr, exitCode } = await runCommand( + [ + "spectrum", + "platforms", + "enable", + "whatsapp", + "--project", + projectId, + "--json", + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ whatsapp: true }); + }); +}); + +describe("photon spectrum users add", () => { + const projectId = "00000000-0000-4000-a000-000000000001"; + const args = [ + "spectrum", + "users", + "add", + "--first-name", + "Ada", + "--last-name", + "Lovelace", + "--email", + "ada@example.com", + "--phone", + "+15551234567", + ]; + + test("fails visibly when the phone has no shared route available", async () => { + resetMockState(); + setMockSpectrumUserAddFailure({ + code: "shared_line_unavailable", + message: "This phone couldn't be connected to a shared iMessage line.", + }); + + const { stdout, stderr, exitCode } = await runCommand( + args, + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + PHOTON_PROJECT_ID: projectId, + }, + }, + ); + + expect(exitCode).toBe(1); + expect(stdout).not.toContain("Added"); + expect(stderr).toContain( + "This phone couldn't be connected to a shared iMessage line. Try another phone or connect a dedicated line.", + ); + }); + + test("prints a structured JSON error and exits one", async () => { + resetMockState(); + setMockSpectrumUserAddFailure({ + code: "shared_line_unavailable", + message: "This phone couldn't be connected to a shared iMessage line.", + }); + + const { stdout, stderr, exitCode } = await runCommand( + [...args, "--json"], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + PHOTON_PROJECT_ID: projectId, + }, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + error: { + code: "shared_line_unavailable", + message: + "This phone couldn't be connected to a shared iMessage line. Try another phone or connect a dedicated line.", + }, + }); + }); + + test("explains that iMessage must be enabled before adding a Spectrum user", async () => { + resetMockState(); + setMockSpectrumUserAddFailure({ + code: "imessage_not_enabled", + message: "Enable iMessage for this project before adding a Spectrum user.", + }); + + const { stdout, stderr, exitCode } = await runCommand( + [...args, "--json"], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + PHOTON_PROJECT_ID: projectId, + }, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + error: { + code: "imessage_not_enabled", + message: "Enable iMessage for this project before adding a Spectrum user.", + }, + }); + }); +}); + describe("photon projects show", () => { test("shows project details", async () => { const { stdout, exitCode } = await runCommand( diff --git a/tests/helpers/mock-server.ts b/tests/helpers/mock-server.ts index 6a4e76d..cb30388 100644 --- a/tests/helpers/mock-server.ts +++ b/tests/helpers/mock-server.ts @@ -22,6 +22,9 @@ import subscriptionActive from "../fixtures/subscription.active.json"; import checkoutResponse from "../fixtures/billing.checkout.json"; import manageResponse from "../fixtures/subscription.manage.json"; +// Deliberately stale copy proves the CLI owns the approved recovery wording. +const STALE_RECOVERY_MESSAGE = "Delete an unused project or contact support."; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let server: any = null; @@ -39,7 +42,31 @@ interface MockState { lineAvatarResponseFault: "missing-avatar-url" | "missing-upload-key" | null; lineProfileRequests: MockLineProfileRequest[]; profileSyncRequests: MockProfileSyncRequest[]; - projectCreateRequests: Record[]; + projectCreateOwnerStatus: + | "skipped_no_phone" + | "skipped_pool_exhausted" + | "failed" + | null; + projectCreateRequests: MockProjectCreateRequest[]; + projectDeleteRequests: string[]; + platformToggleWarning: boolean; + platformToggleRequests: MockPlatformToggleRequest[]; + platforms: Record | null; + spectrumUserAddFailure: { code: string; message: string } | null; +} + +export interface MockProjectCreateRequest { + location?: string; + name?: string; + observability?: boolean; + platforms?: string[]; + template?: boolean; +} + +export interface MockPlatformToggleRequest { + enabled: boolean; + platformId: string; + projectId: string; } export interface MockLineProfileRequest { @@ -71,9 +98,46 @@ const state: MockState = { lineAvatarResponseFault: null, lineProfileRequests: [], profileSyncRequests: [], + projectCreateOwnerStatus: null, projectCreateRequests: [], + projectDeleteRequests: [], + platformToggleWarning: false, + platformToggleRequests: [], + platforms: null, + spectrumUserAddFailure: null, }; +export function setMockProjectCreateOwnerStatus( + status: MockState["projectCreateOwnerStatus"] +): void { + state.projectCreateOwnerStatus = status; +} + +export function getMockProjectCreateRequests(): MockProjectCreateRequest[] { + return state.projectCreateRequests.map((request) => ({ + ...request, + platforms: request.platforms ? [...request.platforms] : undefined, + })); +} + +export function getMockPlatformToggleRequests(): MockPlatformToggleRequest[] { + return state.platformToggleRequests.map((request) => ({ ...request })); +} + +export function setMockPlatformToggleWarning(enabled: boolean): void { + state.platformToggleWarning = enabled; +} + +export function getMockProjectDeleteRequests(): string[] { + return [...state.projectDeleteRequests]; +} + +export function setMockSpectrumUserAddFailure( + failure: MockState["spectrumUserAddFailure"] +): void { + state.spectrumUserAddFailure = failure; +} + export function setMockSubscription(sub: "free" | "active"): void { state.subscription = sub === "active" ? subscriptionActive : subscriptionFree; } @@ -104,10 +168,6 @@ export function getMockLineProfileRequests(): MockLineProfileRequest[] { return state.lineProfileRequests.map((request) => ({ ...request })); } -export function getMockProjectCreateRequests(): Record[] { - return state.projectCreateRequests.map((request) => ({ ...request })); -} - export function resetMockState(): void { state.subscription = subscriptionFree; state.forceUnauthorized = false; @@ -116,7 +176,13 @@ export function resetMockState(): void { state.lineAvatarResponseFault = null; state.lineProfileRequests = []; state.profileSyncRequests = []; + state.projectCreateOwnerStatus = null; state.projectCreateRequests = []; + state.projectDeleteRequests = []; + state.platformToggleWarning = false; + state.platformToggleRequests = []; + state.platforms = null; + state.spectrumUserAddFailure = null; } function requireAuth(headers: Record) { @@ -164,17 +230,6 @@ const app = new Elysia() } return linesFixture; }) - .get("/api/projects/:id/platforms", ({ headers }) => { - const denied = requireAuth(headers as Record); - if (denied) return denied; - if (state.invalidShape.has("platforms")) { - return { imessage: "yes" }; - } - if (state.wrongShape.has("platforms")) { - return [{ platform: "imessage", enabled: true }]; - } - return { imessage: true, whatsapp_business: false }; - }) .get("/api/projects/:id/spectrum/users", ({ headers }) => { const denied = requireAuth(headers as Record); if (denied) return denied; @@ -299,11 +354,106 @@ const app = new Elysia() if (found) return found; return projectFixture; }) + .post("/api/projects/:id/spectrum/users", ({ body, headers, params }) => { + const denied = requireAuth(headers as Record); + if (denied) return denied; + if (state.spectrumUserAddFailure) { + return new Response(JSON.stringify(state.spectrumUserAddFailure), { + status: 409, + headers: { "Content-Type": "application/json" }, + }); + } + const input = body as { + email: string; + firstName: string; + lastName: string; + phoneNumber: string; + }; + return { + success: true as const, + user: { + id: "spectrum-user-1", + projectId: params.id, + type: "shared" as const, + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + phoneNumber: input.phoneNumber, + assignedPhoneNumber: "+15550000001", + createdAt: new Date(0).toISOString(), + meta: null, + }, + }; + }) + .get("/api/projects/:id/platforms", ({ headers }) => { + const denied = requireAuth(headers as Record); + if (denied) return denied; + if (state.invalidShape.has("platforms")) { + return { imessage: "yes" }; + } + if (state.wrongShape.has("platforms")) { + return [{ platform: "imessage", enabled: true }]; + } + return state.platforms ?? { imessage: true, whatsapp_business: false }; + }) + .post( + "/api/projects/:id/platforms/toggle", + ({ body, headers, params }) => { + const denied = requireAuth(headers as Record); + if (denied) return denied; + const input = body as { enabled: boolean; platformId: string }; + state.platformToggleRequests.push({ + ...input, + projectId: params.id, + }); + const platformState = state.platforms ?? {}; + platformState[input.platformId] = input.enabled; + state.platforms = platformState; + const warning = + state.platformToggleWarning + ? { + code: "imessage_connection_missing", + message: STALE_RECOVERY_MESSAGE, + } + : undefined; + return { + success: true as const, + platforms: { ...platformState }, + ...(warning ? { warning } : {}), + }; + } + ) .post("/api/projects", ({ body, headers }) => { const denied = requireAuth(headers as Record); if (denied) return denied; - state.projectCreateRequests.push({ ...(body as Record) }); - return { success: true, id: projectFixture.id }; + const input = body as MockProjectCreateRequest; + state.projectCreateRequests.push({ + ...input, + platforms: input.platforms ? [...input.platforms] : undefined, + }); + const requestsImessage = input.platforms?.includes("imessage") ?? false; + const warning = + requestsImessage && + state.projectCreateOwnerStatus === "skipped_pool_exhausted" + ? { + code: "shared_line_unavailable", + message: STALE_RECOVERY_MESSAGE, + } + : undefined; + return { + success: true as const, + id: projectFixture.id, + ...(requestsImessage && state.projectCreateOwnerStatus + ? { ownerStatus: state.projectCreateOwnerStatus } + : {}), + ...(warning ? { warning } : {}), + }; + }) + .delete("/api/projects/:id", ({ headers, params }) => { + const denied = requireAuth(headers as Record); + if (denied) return denied; + state.projectDeleteRequests.push(params.id); + return { success: true as const }; }) .get("/api/projects/:id/subscription", ({ headers }) => { const denied = requireAuth(headers as Record);