diff --git a/README.md b/README.md index da726c3c..7919038f 100644 --- a/README.md +++ b/README.md @@ -2054,6 +2054,25 @@ test the end of a restriction. `--from-session OTHER_TEST_NUMBER` sends a real s another Test number in the project instead, producing an ordinary `message.received` event you can reply to. Use `--json` for one JSON document. +## Test number phone + +Each Test number has a simulated phone that acts as its main device. These +commands read it, send a text as the phone to another running Test number in +the project, and unlink a device as a person would from Linked devices. Device +0 is the phone itself and cannot be unlinked. They require a server API key +with `sandbox:read` for `get` and `sandbox:write` for `send` and `unlink`, and +Test numbers access. Phone sends share the 30-per-minute project limit with +test events. + +```shell +polymorfa testing phone get --project PROJECT_ID --session my-test-number +polymorfa testing phone send --project PROJECT_ID --session my-test-number \ + --to +15550100002 --text "hello from the phone" +polymorfa testing phone unlink --project PROJECT_ID --session my-test-number --device 74 +``` + +Use `--json` for one JSON document. + ## Local MCP tools The package includes a standards-based stdio MCP server for Codex and Claude: diff --git a/src/commands/testing/phone/get.ts b/src/commands/testing/phone/get.ts new file mode 100644 index 00000000..77b87c17 --- /dev/null +++ b/src/commands/testing/phone/get.ts @@ -0,0 +1,27 @@ +import {BaseCommand} from '../../../base-command.js' +import {phoneTarget, sessionFlag} from '../../../testing-phone.js' + +export default class TestingPhoneGet extends BaseCommand { + static override description = + "Show a Test number's simulated phone: whether it is online and which devices are linked." + static override examples = [ + '<%= config.bin %> testing phone get --session my-test-number --project ', + ] + static override flags = {session: sessionFlag} + async run(): Promise { + const {flags} = await this.parse(TestingPhoneGet) + const {project, session} = phoneTarget(flags) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Testing phone', + }) + const response = await client.testing.getPhone(project, session) + const phone = response.data + const devices = phone.devices.map((device) => device.deviceId).join(', ') + this.writeValue( + {data: phone, metadata: response.metadata}, + `${phone.session} ${phone.phone}: phone ${phone.online ? 'online' : 'offline'}\n` + + `Linked devices: ${devices || 'none'}`, + flags, + ) + } +} diff --git a/src/commands/testing/phone/send.ts b/src/commands/testing/phone/send.ts new file mode 100644 index 00000000..ddfd0fe9 --- /dev/null +++ b/src/commands/testing/phone/send.ts @@ -0,0 +1,37 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {phoneTarget, sessionFlag} from '../../../testing-phone.js' + +export default class TestingPhoneSend extends BaseCommand { + static override description = + "Send a text from a Test number's phone to another running Test number in the project." + static override examples = [ + '<%= config.bin %> testing phone send --session my-test-number --to +15550100002 --text "hello" --project ', + ] + static override flags = { + session: sessionFlag, + to: Flags.string({ + description: 'Another Test number in the project, in E.164 format.', + required: true, + }), + text: Flags.string({description: 'Message text.', required: true}), + } + async run(): Promise { + const {flags} = await this.parse(TestingPhoneSend) + const {project, session} = phoneTarget(flags) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Testing phone', + }) + const response = await client.testing.sendPhoneMessage(project, session, { + to: flags.to, + text: flags.text, + }) + const sent = response.data + this.writeValue( + {data: sent, metadata: response.metadata}, + `Sent from ${sent.session}'s phone to ${sent.to}` + + (sent.messageId ? ` (${sent.messageId}).` : '.'), + flags, + ) + } +} diff --git a/src/commands/testing/phone/unlink.ts b/src/commands/testing/phone/unlink.ts new file mode 100644 index 00000000..e4684704 --- /dev/null +++ b/src/commands/testing/phone/unlink.ts @@ -0,0 +1,37 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {phoneTarget, sessionFlag} from '../../../testing-phone.js' + +export default class TestingPhoneUnlink extends BaseCommand { + static override description = + "Unlink a device from a Test number's phone, as from Linked devices on a phone. Device 0 is the phone and cannot be unlinked." + static override examples = [ + '<%= config.bin %> testing phone unlink --session my-test-number --device 74 --project ', + ] + static override flags = { + session: sessionFlag, + device: Flags.integer({ + description: 'Linked device ID, as shown by testing phone get.', + min: 1, + max: 99, + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(TestingPhoneUnlink) + const {project, session} = phoneTarget(flags) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Testing phone', + }) + const response = await client.testing.unlinkPhoneDevice( + project, + session, + flags.device, + ) + this.writeValue( + {data: response.data, metadata: response.metadata}, + `Unlinked device ${response.data.deviceId} from ${response.data.session}'s phone.`, + flags, + ) + } +} diff --git a/src/testing-phone.ts b/src/testing-phone.ts new file mode 100644 index 00000000..4c5346a7 --- /dev/null +++ b/src/testing-phone.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +export const sessionFlag = Flags.string({ + description: 'Test number session name in the project.', +}) + +/** The project and Test number every phone command acts on. */ +export function phoneTarget(flags: { + project?: string | undefined + session?: string | undefined +}): { + project: string + session: string +} { + if (!flags.project) + throw new PolymorfaConfigurationError( + 'Select a project with --project.', + 'project', + ) + if (!flags.session) + throw new PolymorfaConfigurationError( + 'Select the Test number with --session.', + 'session', + ) + return {project: flags.project, session: flags.session} +} diff --git a/test/commands/testing/phone.test.ts b/test/commands/testing/phone.test.ts new file mode 100644 index 00000000..a35a9f59 --- /dev/null +++ b/test/commands/testing/phone.test.ts @@ -0,0 +1,101 @@ +import {runCommand} from '@oclif/test' +import {expect, test} from 'vitest' +import { + startHttpSequenceServer, + withEnvironment, +} from '../../helpers/http-server.js' + +const projectId = '11111111-1111-4111-8111-111111111111' +const apiKey = `pmfa_${'A'.repeat(72)}` +const base = `/messaging/testing/${projectId}/numbers/test-a/phone` + +async function run( + command: string, + responses: Parameters[0], +) { + const server = await startHttpSequenceServer(responses) + try { + const result = await withEnvironment( + { + POLYMORFA_API_KEY: apiKey, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand(command), + ) + return {result, requests: server.requests} + } finally { + await server.close() + } +} + +test('shows the phone and its linked devices', async () => { + const {result, requests} = await run( + `testing phone get --project ${projectId} --session test-a`, + [ + { + status: 200, + body: { + session: 'test-a', + phone: '+15550100001', + online: true, + devices: [{deviceId: 33}, {deviceId: 74}], + }, + }, + ], + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('test-a +15550100001: phone online') + expect(result.stdout).toContain('Linked devices: 33, 74') + expect(requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET ${base}`, + ]) +}) + +test('sends a message from the phone and prints JSON', async () => { + const sent = {session: 'test-a', to: '+15550100002', messageId: '3EB0AA'} + const {result, requests} = await run( + `testing phone send --project ${projectId} --session test-a --to +15550100002 --text hello --json`, + [{status: 202, body: sent}], + ) + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual(sent) + expect(requests[0]!.method).toBe('POST') + expect(requests[0]!.url).toBe(`${base}/messages`) + expect(JSON.parse(requests[0]!.body)).toEqual({ + to: '+15550100002', + text: 'hello', + }) +}) + +test('unlinks a device', async () => { + const {result, requests} = await run( + `testing phone unlink --project ${projectId} --session test-a --device 74`, + [{status: 200, body: {session: 'test-a', deviceId: 74, unlinked: true}}], + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain("Unlinked device 74 from test-a's phone.") + expect(requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `POST ${base}/devices/74/unlink`, + ]) +}) + +test('refuses device 0 before any request', async () => { + const {result, requests} = await run( + `testing phone unlink --project ${projectId} --session test-a --device 0`, + [], + ) + expect(result.error).toBeDefined() + expect(requests).toHaveLength(0) +}) + +test('asks for the Test number', async () => { + const {result, requests} = await run( + `testing phone get --project ${projectId}`, + [], + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('--session') + expect(requests).toHaveLength(0) +})