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 1/5] 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); From 6a110889ae9f62dc2b40264b4461956fd9a3f71f Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:07:21 +0800 Subject: [PATCH 2/5] fix(spectrum): scope capacity warning behavior --- README.md | 3 +- src/commands/projects.ts | 59 +++++++++------- src/commands/spectrum/users.ts | 3 +- src/lib/types.ts | 4 +- tests/_setup.ts | 3 - tests/contract/projects.contract.test.ts | 86 ++---------------------- tests/helpers/mock-server.ts | 23 ++----- 7 files changed, 46 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index b27163c..2ef693d 100644 --- a/README.md +++ b/README.md @@ -245,8 +245,7 @@ photon ├── projects │ ├── ls list projects │ ├── show [id] project detail -│ ├── create [--name --location --platforms ] -│ │ new project; no platform when omitted +│ ├── create [--name --location --spectrum] new project │ ├── 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 4f7f6a0..c8dcc07 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -49,28 +49,16 @@ const PROJECT_CREATE_WARNINGS = { }, } 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 + typeof value === "string" && + Object.prototype.hasOwnProperty.call(PROJECT_CREATE_WARNINGS, value) ); } function readProjectCreateWarning(result: { - ownerStatus?: unknown; warning?: unknown; }): ProjectCreateWarning | undefined { if (result.warning && typeof result.warning === "object") { @@ -82,10 +70,7 @@ function readProjectCreateWarning(result: { return PROJECT_CREATE_WARNINGS[warning.code]; } } - if (!isOwnerWarningStatus(result.ownerStatus)) return undefined; - return PROJECT_CREATE_WARNINGS[ - OWNER_STATUS_WARNING_CODES[result.ownerStatus] - ]; + return undefined; } export function registerProjectsCommand(program: Command): void { @@ -278,10 +263,7 @@ 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(", ")}); omit to enable none` - ) + .option("--platforms ", `comma-separated platforms (${PLATFORMS.join(", ")})`) .option("--template", "use as template") .option("--observability", "enable observability") .option("--api-host ", "API host URL (defaults to PHOTON_API_HOST or built-in production)") @@ -381,9 +363,6 @@ 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()) { @@ -394,7 +373,7 @@ async function fillCreateOpts(opts: CreateOpts): Promise { return { name: opts.name.trim(), location: opts.location ?? "United States", - platforms, + platforms: opts.platforms !== undefined ? parsePlatforms(opts.platforms) : [], template: opts.template ?? false, observability: opts.observability ?? false, }; @@ -428,6 +407,16 @@ 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)); @@ -436,6 +425,24 @@ 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/users.ts b/src/commands/spectrum/users.ts index d4a85ca..de611ff 100644 --- a/src/commands/spectrum/users.ts +++ b/src/commands/spectrum/users.ts @@ -179,8 +179,7 @@ function isSpectrumUserAddFailureCode( return ( value === "imessage_not_enabled" || value === "shared_line_unavailable" || - value === "shared_user_create_failed" || - value === "shared_user_limit_reached" + value === "shared_user_create_failed" ); } diff --git a/src/lib/types.ts b/src/lib/types.ts index f393001..8bc15f4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -22,7 +22,6 @@ export interface ProjectCreateWarning { export interface ProjectCreateResult { error?: string; id?: string; - ownerStatus?: unknown; warning?: unknown; } @@ -41,8 +40,7 @@ export interface PlatformToggleResult { export type SpectrumUserAddFailureCode = | "imessage_not_enabled" | "shared_line_unavailable" - | "shared_user_create_failed" - | "shared_user_limit_reached"; + | "shared_user_create_failed"; export interface SpectrumUserAddFailure { code: SpectrumUserAddFailureCode; diff --git a/tests/_setup.ts b/tests/_setup.ts index e433d1d..9bd5196 100644 --- a/tests/_setup.ts +++ b/tests/_setup.ts @@ -9,9 +9,6 @@ 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 3395951..51a49ff 100644 --- a/tests/contract/projects.contract.test.ts +++ b/tests/contract/projects.contract.test.ts @@ -12,7 +12,7 @@ import { getMockProjectDeleteRequests, resetMockState, setMockPlatformToggleWarning, - setMockProjectCreateOwnerStatus, + setMockProjectCreateWarning, setMockSpectrumUserAddFailure, startMockServer, stopMockServer, @@ -156,7 +156,7 @@ 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"); + setMockProjectCreateWarning(true); const { stdout, stderr, exitCode } = await runCommand( [ @@ -185,7 +185,7 @@ describe("photon projects create", () => { test("create --json includes the non-blocking warning", async () => { resetMockState(); - setMockProjectCreateOwnerStatus("skipped_pool_exhausted"); + setMockProjectCreateWarning(true); const { stdout, stderr, exitCode } = await runCommand( [ @@ -221,7 +221,7 @@ describe("photon projects create", () => { test("still warns when deletion does not restore shared capacity", async () => { resetMockState(); - setMockProjectCreateOwnerStatus("skipped_pool_exhausted"); + setMockProjectCreateWarning(true); const projectId = "00000000-0000-4000-a000-000000000001"; const env = { PHOTON_TOKEN: "test-token", @@ -254,84 +254,6 @@ describe("photon projects create", () => { ); }); - 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", () => { diff --git a/tests/helpers/mock-server.ts b/tests/helpers/mock-server.ts index cb30388..681d803 100644 --- a/tests/helpers/mock-server.ts +++ b/tests/helpers/mock-server.ts @@ -42,11 +42,7 @@ interface MockState { lineAvatarResponseFault: "missing-avatar-url" | "missing-upload-key" | null; lineProfileRequests: MockLineProfileRequest[]; profileSyncRequests: MockProfileSyncRequest[]; - projectCreateOwnerStatus: - | "skipped_no_phone" - | "skipped_pool_exhausted" - | "failed" - | null; + projectCreateWarning: boolean; projectCreateRequests: MockProjectCreateRequest[]; projectDeleteRequests: string[]; platformToggleWarning: boolean; @@ -98,7 +94,7 @@ const state: MockState = { lineAvatarResponseFault: null, lineProfileRequests: [], profileSyncRequests: [], - projectCreateOwnerStatus: null, + projectCreateWarning: false, projectCreateRequests: [], projectDeleteRequests: [], platformToggleWarning: false, @@ -107,10 +103,8 @@ const state: MockState = { spectrumUserAddFailure: null, }; -export function setMockProjectCreateOwnerStatus( - status: MockState["projectCreateOwnerStatus"] -): void { - state.projectCreateOwnerStatus = status; +export function setMockProjectCreateWarning(enabled: boolean): void { + state.projectCreateWarning = enabled; } export function getMockProjectCreateRequests(): MockProjectCreateRequest[] { @@ -176,7 +170,7 @@ export function resetMockState(): void { state.lineAvatarResponseFault = null; state.lineProfileRequests = []; state.profileSyncRequests = []; - state.projectCreateOwnerStatus = null; + state.projectCreateWarning = false; state.projectCreateRequests = []; state.projectDeleteRequests = []; state.platformToggleWarning = false; @@ -432,9 +426,7 @@ const app = new Elysia() platforms: input.platforms ? [...input.platforms] : undefined, }); const requestsImessage = input.platforms?.includes("imessage") ?? false; - const warning = - requestsImessage && - state.projectCreateOwnerStatus === "skipped_pool_exhausted" + const warning = requestsImessage && state.projectCreateWarning ? { code: "shared_line_unavailable", message: STALE_RECOVERY_MESSAGE, @@ -443,9 +435,6 @@ const app = new Elysia() return { success: true as const, id: projectFixture.id, - ...(requestsImessage && state.projectCreateOwnerStatus - ? { ownerStatus: state.projectCreateOwnerStatus } - : {}), ...(warning ? { warning } : {}), }; }) From a89a8af4ad4072c08026702a7e0c75ab9b3cb3a6 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:17:47 +0800 Subject: [PATCH 3/5] fix(spectrum): handle incomplete warning responses --- src/commands/projects.ts | 7 +-- src/commands/spectrum/platforms.ts | 3 ++ tests/contract/projects.contract.test.ts | 54 ++++++++++++++++++++++++ tests/helpers/mock-server.ts | 23 +++++++++- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/commands/projects.ts b/src/commands/projects.ts index c8dcc07..efce14f 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -62,11 +62,8 @@ function readProjectCreateWarning(result: { 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" - ) { + const warning = result.warning as { code?: unknown }; + if (isProjectCreateWarningCode(warning.code)) { return PROJECT_CREATE_WARNINGS[warning.code]; } } diff --git a/src/commands/spectrum/platforms.ts b/src/commands/spectrum/platforms.ts index 114f70e..1a665b3 100644 --- a/src/commands/spectrum/platforms.ts +++ b/src/commands/spectrum/platforms.ts @@ -111,6 +111,9 @@ 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)}`); + if (!data) { + die("Server did not return a platform result."); + } const result = data as PlatformToggleResult; if (result.error) { die(result.error, { diff --git a/tests/contract/projects.contract.test.ts b/tests/contract/projects.contract.test.ts index 51a49ff..1a88d35 100644 --- a/tests/contract/projects.contract.test.ts +++ b/tests/contract/projects.contract.test.ts @@ -11,6 +11,7 @@ import { getMockProjectCreateRequests, getMockProjectDeleteRequests, resetMockState, + setMockPlatformToggleEmptyResponse, setMockPlatformToggleWarning, setMockProjectCreateWarning, setMockSpectrumUserAddFailure, @@ -219,6 +220,34 @@ describe("photon projects create", () => { expect(getMockProjectDeleteRequests()).toEqual([]); }); + test("uses CLI warning copy when the API returns only a known code", async () => { + resetMockState(); + setMockProjectCreateWarning(true, false); + + 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", + ); + }); + test("still warns when deletion does not restore shared capacity", async () => { resetMockState(); setMockProjectCreateWarning(true); @@ -401,6 +430,31 @@ describe("photon spectrum platforms enable", () => { expect(stderr).toBe(""); expect(JSON.parse(stdout)).toEqual({ whatsapp: true }); }); + + test("fails clearly when platform toggle returns no result", async () => { + resetMockState(); + setMockPlatformToggleEmptyResponse(true); + + const { stderr, exitCode } = await runCommand( + [ + "spectrum", + "platforms", + "enable", + "imessage", + "--project", + projectId, + ], + { + env: { + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Server did not return a platform result."); + }); }); describe("photon spectrum users add", () => { diff --git a/tests/helpers/mock-server.ts b/tests/helpers/mock-server.ts index 681d803..befb17c 100644 --- a/tests/helpers/mock-server.ts +++ b/tests/helpers/mock-server.ts @@ -43,8 +43,10 @@ interface MockState { lineProfileRequests: MockLineProfileRequest[]; profileSyncRequests: MockProfileSyncRequest[]; projectCreateWarning: boolean; + projectCreateWarningMessage: boolean; projectCreateRequests: MockProjectCreateRequest[]; projectDeleteRequests: string[]; + platformToggleEmptyResponse: boolean; platformToggleWarning: boolean; platformToggleRequests: MockPlatformToggleRequest[]; platforms: Record | null; @@ -95,16 +97,22 @@ const state: MockState = { lineProfileRequests: [], profileSyncRequests: [], projectCreateWarning: false, + projectCreateWarningMessage: true, projectCreateRequests: [], projectDeleteRequests: [], + platformToggleEmptyResponse: false, platformToggleWarning: false, platformToggleRequests: [], platforms: null, spectrumUserAddFailure: null, }; -export function setMockProjectCreateWarning(enabled: boolean): void { +export function setMockProjectCreateWarning( + enabled: boolean, + includeMessage = true, +): void { state.projectCreateWarning = enabled; + state.projectCreateWarningMessage = includeMessage; } export function getMockProjectCreateRequests(): MockProjectCreateRequest[] { @@ -122,6 +130,10 @@ export function setMockPlatformToggleWarning(enabled: boolean): void { state.platformToggleWarning = enabled; } +export function setMockPlatformToggleEmptyResponse(enabled: boolean): void { + state.platformToggleEmptyResponse = enabled; +} + export function getMockProjectDeleteRequests(): string[] { return [...state.projectDeleteRequests]; } @@ -171,8 +183,10 @@ export function resetMockState(): void { state.lineProfileRequests = []; state.profileSyncRequests = []; state.projectCreateWarning = false; + state.projectCreateWarningMessage = true; state.projectCreateRequests = []; state.projectDeleteRequests = []; + state.platformToggleEmptyResponse = false; state.platformToggleWarning = false; state.platformToggleRequests = []; state.platforms = null; @@ -400,6 +414,9 @@ const app = new Elysia() ...input, projectId: params.id, }); + if (state.platformToggleEmptyResponse) { + return new Response(null, { status: 204 }); + } const platformState = state.platforms ?? {}; platformState[input.platformId] = input.enabled; state.platforms = platformState; @@ -429,7 +446,9 @@ const app = new Elysia() const warning = requestsImessage && state.projectCreateWarning ? { code: "shared_line_unavailable", - message: STALE_RECOVERY_MESSAGE, + ...(state.projectCreateWarningMessage + ? { message: STALE_RECOVERY_MESSAGE } + : {}), } : undefined; return { From 640ef21ee5277fe04d270ae5e700efea8b21ec72 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:22:00 +0800 Subject: [PATCH 4/5] refactor(spectrum): centralize user response dto --- src/commands/spectrum/users.ts | 16 +++------------- src/lib/types.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/commands/spectrum/users.ts b/src/commands/spectrum/users.ts index de611ff..707be23 100644 --- a/src/commands/spectrum/users.ts +++ b/src/commands/spectrum/users.ts @@ -7,8 +7,10 @@ import { confirmDestructive } from "~/lib/interactive.ts"; import { c, die, formatApiError, printJson, printTable } from "~/lib/output.ts"; import { requireArrayField } from "~/lib/shape.ts"; import type { + SpectrumUser, SpectrumUserAddFailure, SpectrumUserAddFailureCode, + SpectrumUserAddResult, } from "~/lib/types.ts"; import { isInteractive } from "~/lib/tty.ts"; @@ -103,11 +105,7 @@ export function registerSpectrumUsers(spectrum: Command): void { opts.json ?? false, ); } - const result = data as { - success?: true; - user?: SpectrumUser; - error?: string; - }; + const result = data as SpectrumUserAddResult; if (result.error) { failSpectrumUserAdd( { @@ -165,14 +163,6 @@ export function registerSpectrumUsers(spectrum: Command): void { }); } -interface SpectrumUser { - id: string; - firstName?: string | null; - lastName?: string | null; - email?: string | null; - phoneNumber?: string | null; -} - function isSpectrumUserAddFailureCode( value: unknown ): value is SpectrumUserAddFailureCode { diff --git a/src/lib/types.ts b/src/lib/types.ts index 8bc15f4..3fcb46a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -46,3 +46,17 @@ export interface SpectrumUserAddFailure { code: SpectrumUserAddFailureCode; message: string; } + +export interface SpectrumUser { + email?: string | null; + firstName?: string | null; + id: string; + lastName?: string | null; + phoneNumber?: string | null; +} + +export interface SpectrumUserAddResult { + error?: string; + success?: true; + user?: SpectrumUser; +} From 3d778a568641ff4ebd95938b1ff7acaf58837dc2 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:30:56 +0800 Subject: [PATCH 5/5] fix(spectrum): validate user creation response --- src/commands/spectrum/users.ts | 29 +++++++++++++++++++----- src/lib/types.ts | 8 +++---- tests/contract/projects.contract.test.ts | 19 ++++++++++++++++ tests/helpers/mock-server.ts | 1 + 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/commands/spectrum/users.ts b/src/commands/spectrum/users.ts index 707be23..0c35880 100644 --- a/src/commands/spectrum/users.ts +++ b/src/commands/spectrum/users.ts @@ -99,14 +99,14 @@ export function registerSpectrumUsers(spectrum: Command): void { }); if (status === 401) throw new SessionExpiredError(resolved.name); if (error) failSpectrumUserAdd(error, opts.json ?? false); - if (!data) { + const result = parseSpectrumUserAddResult(data); + if (!result) { failSpectrumUserAdd( - "Server did not return a Spectrum user result.", + "Server did not return a valid Spectrum user result.", opts.json ?? false, ); } - const result = data as SpectrumUserAddResult; - if (result.error) { + if ("error" in result) { failSpectrumUserAdd( { code: "shared_user_create_failed", @@ -116,11 +116,11 @@ export function registerSpectrumUsers(spectrum: Command): void { ); } - if (opts.json) return printJson(result.user ?? {}); + if (opts.json) return printJson(result.user); const u = result.user; console.log( c.success( - `Added ${formatName(u ?? filled)} ${u?.id ? c.dim(`(${u.id})`) : ""}` + `Added ${formatName(u)} ${c.dim(`(${u.id})`)}` ) ); if (opts.invite) console.log(c.dim(" Invite sent.")); @@ -163,6 +163,23 @@ export function registerSpectrumUsers(spectrum: Command): void { }); } +function parseSpectrumUserAddResult( + value: unknown +): SpectrumUserAddResult | null { + if (!(value && typeof value === "object") || Array.isArray(value)) return null; + const result = value as Record; + if (typeof result.error === "string") return { error: result.error }; + if ( + result.success !== true || + !(result.user && typeof result.user === "object") || + Array.isArray(result.user) || + typeof (result.user as Record).id !== "string" + ) { + return null; + } + return { success: true, user: result.user as SpectrumUser }; +} + function isSpectrumUserAddFailureCode( value: unknown ): value is SpectrumUserAddFailureCode { diff --git a/src/lib/types.ts b/src/lib/types.ts index 3fcb46a..d3b9d8b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,8 +55,6 @@ export interface SpectrumUser { phoneNumber?: string | null; } -export interface SpectrumUserAddResult { - error?: string; - success?: true; - user?: SpectrumUser; -} +export type SpectrumUserAddResult = + | { error: string } + | { success: true; user: SpectrumUser }; diff --git a/tests/contract/projects.contract.test.ts b/tests/contract/projects.contract.test.ts index 1a88d35..ee42be5 100644 --- a/tests/contract/projects.contract.test.ts +++ b/tests/contract/projects.contract.test.ts @@ -11,6 +11,7 @@ import { getMockProjectCreateRequests, getMockProjectDeleteRequests, resetMockState, + setMockInvalidShape, setMockPlatformToggleEmptyResponse, setMockPlatformToggleWarning, setMockProjectCreateWarning, @@ -554,6 +555,24 @@ describe("photon spectrum users add", () => { }, }); }); + + test("fails instead of reporting success for an incomplete response", async () => { + setMockInvalidShape("users"); + + 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( + "Server did not return a valid Spectrum user result.", + ); + }); }); describe("photon projects show", () => { diff --git a/tests/helpers/mock-server.ts b/tests/helpers/mock-server.ts index befb17c..57e81e1 100644 --- a/tests/helpers/mock-server.ts +++ b/tests/helpers/mock-server.ts @@ -365,6 +365,7 @@ const app = new Elysia() .post("/api/projects/:id/spectrum/users", ({ body, headers, params }) => { const denied = requireAuth(headers as Record); if (denied) return denied; + if (state.invalidShape.has("users")) return {}; if (state.spectrumUserAddFailure) { return new Response(JSON.stringify(state.spectrumUserAddFailure), { status: 409,