Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions src/commands/testing/phone/get.ts
Original file line number Diff line number Diff line change
@@ -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 <projectId>',
]
static override flags = {session: sessionFlag}
async run(): Promise<void> {
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)

Check failure on line 17 in src/commands/testing/phone/get.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Property 'getPhone' does not exist on type 'TestingResource'.
const phone = response.data
const devices = phone.devices.map((device) => device.deviceId).join(', ')

Check failure on line 19 in src/commands/testing/phone/get.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Parameter 'device' implicitly has an 'any' type.
this.writeValue(
{data: phone, metadata: response.metadata},
`${phone.session} ${phone.phone}: phone ${phone.online ? 'online' : 'offline'}\n` +
`Linked devices: ${devices || 'none'}`,
flags,
)
}
}
37 changes: 37 additions & 0 deletions src/commands/testing/phone/send.ts
Original file line number Diff line number Diff line change
@@ -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 <projectId>',
]
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<void> {
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, {

Check failure on line 25 in src/commands/testing/phone/send.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Property 'sendPhoneMessage' does not exist on type 'TestingResource'.
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,
)
}
}
37 changes: 37 additions & 0 deletions src/commands/testing/phone/unlink.ts
Original file line number Diff line number Diff line change
@@ -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 <projectId>',
]
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<void> {
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(

Check failure on line 26 in src/commands/testing/phone/unlink.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Property 'unlinkPhoneDevice' does not exist on type 'TestingResource'.
project,
session,
flags.device,
)
this.writeValue(
{data: response.data, metadata: response.metadata},
`Unlinked device ${response.data.deviceId} from ${response.data.session}'s phone.`,
flags,
)
}
}
27 changes: 27 additions & 0 deletions src/testing-phone.ts
Original file line number Diff line number Diff line change
@@ -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}
}
101 changes: 101 additions & 0 deletions test/commands/testing/phone.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof startHttpSequenceServer>[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)
})
Loading