diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 150cb42..efce14f 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -20,12 +20,56 @@ 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; + +function isProjectCreateWarningCode( + value: unknown +): value is ProjectCreateWarningCode { + return ( + typeof value === "string" && + Object.prototype.hasOwnProperty.call(PROJECT_CREATE_WARNINGS, value) + ); +} + +function readProjectCreateWarning(result: { + warning?: unknown; +}): ProjectCreateWarning | undefined { + if (result.warning && typeof result.warning === "object") { + const warning = result.warning as { code?: unknown }; + if (isProjectCreateWarningCode(warning.code)) { + return PROJECT_CREATE_WARNINGS[warning.code]; + } + } + return undefined; +} + export function registerProjectsCommand(program: Command): void { const projects = program .command("projects") @@ -241,7 +285,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 +296,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 +323,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}'`) ); diff --git a/src/commands/spectrum/platforms.ts b/src/commands/spectrum/platforms.ts index 4dc5bf8..1a665b3 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,10 @@ 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; - }; + if (!data) { + die("Server did not return a platform result."); + } + const result = data as PlatformToggleResult; if (result.error) { die(result.error, { hint: @@ -105,6 +124,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..0c35880 100644 --- a/src/commands/spectrum/users.ts +++ b/src/commands/spectrum/users.ts @@ -6,6 +6,12 @@ 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 { + SpectrumUser, + SpectrumUserAddFailure, + SpectrumUserAddFailureCode, + SpectrumUserAddResult, +} from "~/lib/types.ts"; import { isInteractive } from "~/lib/tty.ts"; export function registerSpectrumUsers(spectrum: Command): void { @@ -92,15 +98,29 @@ 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); + const result = parseSpectrumUserAddResult(data); + if (!result) { + failSpectrumUserAdd( + "Server did not return a valid Spectrum user result.", + opts.json ?? false, + ); + } + if ("error" in result) { + failSpectrumUserAdd( + { + code: "shared_user_create_failed", + message: result.error, + }, + opts.json ?? false, + ); + } - 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.")); @@ -143,12 +163,73 @@ export function registerSpectrumUsers(spectrum: Command): void { }); } -interface SpectrumUser { - id: string; - firstName?: string | null; - lastName?: string | null; - email?: string | null; - phoneNumber?: string | null; +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 { + return ( + value === "imessage_not_enabled" || + value === "shared_line_unavailable" || + value === "shared_user_create_failed" + ); +} + +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 { diff --git a/src/lib/types.ts b/src/lib/types.ts index 179f417..d3b9d8b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,4 +9,52 @@ * 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; + 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"; + +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 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 cd73990..ee42be5 100644 --- a/tests/contract/projects.contract.test.ts +++ b/tests/contract/projects.contract.test.ts @@ -7,8 +7,15 @@ import { test, } from "bun:test"; import { + getMockPlatformToggleRequests, getMockProjectCreateRequests, + getMockProjectDeleteRequests, resetMockState, + setMockInvalidShape, + setMockPlatformToggleEmptyResponse, + setMockPlatformToggleWarning, + setMockProjectCreateWarning, + setMockSpectrumUserAddFailure, startMockServer, stopMockServer, } from "../helpers/mock-server.ts"; @@ -148,6 +155,426 @@ describe("photon projects list", () => { }); }); +describe("photon projects create", () => { + test("creates the project and warns when owner enrollment is exhausted", async () => { + resetMockState(); + setMockProjectCreateWarning(true); + + 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(); + setMockProjectCreateWarning(true); + + 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("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); + 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", + ); + }); + +}); + +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 }); + }); + + 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", () => { + 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.", + }, + }); + }); + + 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", () => { 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..57e81e1 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,29 @@ interface MockState { lineAvatarResponseFault: "missing-avatar-url" | "missing-upload-key" | null; lineProfileRequests: MockLineProfileRequest[]; profileSyncRequests: MockProfileSyncRequest[]; - projectCreateRequests: Record[]; + projectCreateWarning: boolean; + projectCreateWarningMessage: boolean; + projectCreateRequests: MockProjectCreateRequest[]; + projectDeleteRequests: string[]; + platformToggleEmptyResponse: boolean; + 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 +96,54 @@ const state: MockState = { lineAvatarResponseFault: null, lineProfileRequests: [], profileSyncRequests: [], + projectCreateWarning: false, + projectCreateWarningMessage: true, projectCreateRequests: [], + projectDeleteRequests: [], + platformToggleEmptyResponse: false, + platformToggleWarning: false, + platformToggleRequests: [], + platforms: null, + spectrumUserAddFailure: null, }; +export function setMockProjectCreateWarning( + enabled: boolean, + includeMessage = true, +): void { + state.projectCreateWarning = enabled; + state.projectCreateWarningMessage = includeMessage; +} + +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 setMockPlatformToggleEmptyResponse(enabled: boolean): void { + state.platformToggleEmptyResponse = 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 +174,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 +182,15 @@ export function resetMockState(): void { state.lineAvatarResponseFault = null; 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; + state.spectrumUserAddFailure = null; } function requireAuth(headers: Record) { @@ -164,17 +238,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 +362,107 @@ 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.invalidShape.has("users")) return {}; + 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, + }); + if (state.platformToggleEmptyResponse) { + return new Response(null, { status: 204 }); + } + 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.projectCreateWarning + ? { + code: "shared_line_unavailable", + ...(state.projectCreateWarningMessage + ? { message: STALE_RECOVERY_MESSAGE } + : {}), + } + : undefined; + return { + success: true as const, + id: projectFixture.id, + ...(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);