From b20fe230c8fc5d0ed41dfc439cdc0a6890348ae2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:10:12 +0200 Subject: [PATCH] feat(cli): add confirmed campaign test send --- README.md | 14 +++++++ src/commands/campaign/test-send.ts | 51 +++++++++++++++++++++++++ src/services/campaigns.ts | 38 +++++++++++++++++++ test/commands/campaigns.test.ts | 60 ++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 src/commands/campaign/test-send.ts diff --git a/README.md b/README.md index 3845eaa7..3ca4b0e4 100644 --- a/README.md +++ b/README.md @@ -1336,6 +1336,11 @@ polymorfa campaign list --project-slug launch-project polymorfa campaign create --project-slug launch-project --input campaign.json polymorfa campaign view campaign_123 --project-slug launch-project polymorfa campaign analytics campaign_123 --project-slug launch-project +polymorfa campaign test-send campaign_123 \ + --project-slug launch-project \ + --input test-send.json \ + --idempotency-key campaign-test-001 \ + --yes polymorfa campaign launch campaign_123 \ --project-slug launch-project \ @@ -1355,6 +1360,15 @@ Check the campaign with `polymorfa campaign view`. When an operation ID is returned, follow it with `polymorfa operation wait` or `polymorfa operation watch`; see [Operations](#operations). +`campaign test-send` sends one real message from a draft to a team-connected +seed number. Its JSON input requires `sessionId` (the sending number's session +ID) and `to` (the seed phone in E.164 format); `sampleRecipientId` and +`variables` are optional. The command requires `--yes` and an explicit +`--idempotency-key`. A timeout or `result_unknown` does not prove the message +failed. Check the seed phone and reuse the same key to reconcile the attempt; +do not send with a new key until you know the outcome. Test sends are limited +to 20 per team and 5 per seed number in a rolling hour. + Stop is terminal and requires `--yes`. A campaign that has an active delivery run answers with an operation ID, as the other lifecycle commands do. A campaign with no active run is cancelled immediately and answers without one; diff --git a/src/commands/campaign/test-send.ts b/src/commands/campaign/test-send.ts new file mode 100644 index 00000000..a7256a9d --- /dev/null +++ b/src/commands/campaign/test-send.ts @@ -0,0 +1,51 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import { + renderCampaignTestSend, + testSendCampaign, +} from '../../services/campaigns.js' + +export default class CampaignTestSend extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Draft campaign ID.', required: true}), + } + + static override description = + 'Send one draft campaign test to a team-connected seed number.' + + static override flags = { + 'idempotency-key': Flags.string({ + description: + 'Required deduplication key. Reuse this key after an uncertain result; never retry with a new key before checking the seed phone.', + required: true, + }), + input: Flags.string({ + description: + 'Read one JSON object with sessionId, to, and optional sampleRecipientId or variables from a file or stdin (-).', + required: true, + }), + 'project-slug': Flags.string({ + description: 'Project slug in the Messaging API path.', + required: true, + }), + yes: Flags.boolean({ + description: + 'Confirm sending one real WhatsApp message to the seed number.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignTestSend) + const result = await testSendCampaign( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + await readJsonInput(flags.input), + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderCampaignTestSend(result.data), flags) + } +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 89a2b155..b886b40f 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -9,6 +9,8 @@ import { type CampaignRecipientStatus, type CampaignRequeueResult, type CampaignStopOperation, + type CampaignTestSend, + type CampaignTestSendRequest, type CreateCampaignRequest, type InvalidRecipientRow, type ListCampaignRecipientsParams, @@ -84,6 +86,22 @@ export async function launchCampaign( return result(response.data.data, response.metadata) } +export async function testSendCampaign( + client: MessagingClient, + projectSlug: string, + campaignId: string, + input: unknown, + options: RequestOptions, +): Promise> { + const response = await client.campaigns.testSend( + projectSlug, + campaignId, + campaignTestSendInput(input), + options, + ) + return result(response.data.data, response.metadata) +} + export async function pauseCampaign( client: MessagingClient, projectSlug: string, @@ -258,6 +276,10 @@ export function renderCampaignRequeue(result: CampaignRequeueResult): string { return `Requeued ${result.requeued} ${result.requeued === 1 ? 'recipient' : 'recipients'}.` } +export function renderCampaignTestSend(testSend: CampaignTestSend): string { + return `Sent one campaign test to ${testSend.to} from ${testSend.from.phone}. Test ID: ${testSend.id}.` +} + /** * Stop answers with a null operation ID when the campaign had no delivery run * to stop. Saying "accepted as operation null" would be untrue, so the two @@ -351,6 +373,22 @@ function campaignInput(input: unknown): CreateCampaignRequest { return input as unknown as CreateCampaignRequest } +function campaignTestSendInput(input: unknown): CampaignTestSendRequest { + if ( + !isRecord(input) || + typeof input.sessionId !== 'string' || + input.sessionId.trim().length === 0 || + typeof input.to !== 'string' || + input.to.trim().length === 0 + ) { + throw new PolymorfaValidationError( + 'Campaign test input must contain one JSON object with sessionId and to.', + {code: 'invalid_campaign_test_input'}, + ) + } + return input as unknown as CampaignTestSendRequest +} + function result(data: T, metadata: ResponseMetadata): CampaignResult { return { data, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index 41c71447..732508c3 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -175,6 +175,66 @@ describe('Messaging campaign commands', () => { expect(server.requests).toHaveLength(0) }) + test('sends one campaign test with a reusable key and renders its receipt', async () => { + const input = await jsonInput('test-send.json', { + sessionId: '00000000-0000-4000-8000-000000000001', + to: '+15550000002', + variables: {firstName: 'Ada'}, + }) + const receipt = { + campaignId: 'campaign_1', + createdAt: 1_780_000_000_000, + from: { + phone: '+15550000001', + sessionId: '00000000-0000-4000-8000-000000000001', + }, + id: 'test_1', + messageId: 'wamid.1', + status: 'sent', + to: '+15550000002', + variables: {firstName: 'Ada'}, + } + server = await startHttpServer({body: {data: receipt, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign test-send campaign_1 --project-slug project_1 --input ${input} --idempotency-key test_1 --yes`, + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + 'Sent one campaign test to +15550000002 from +15550000001. Test ID: test_1.', + ) + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({ + sessionId: '00000000-0000-4000-8000-000000000001', + to: '+15550000002', + variables: {firstName: 'Ada'}, + }), + method: 'POST', + url: '/messaging/projects/project_1/campaigns/campaign_1/test-send', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('test_1') + }) + + test('requires explicit send confirmation before contacting the API', async () => { + const input = await jsonInput('test-send.json', { + sessionId: '00000000-0000-4000-8000-000000000001', + to: '+15550000002', + }) + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign test-send campaign_1 --project-slug project_1 --input ${input} --idempotency-key test_1`, + ), + ) + + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + test('pauses, resumes, and explicitly stops through distinct lifecycle routes', async () => { server = await startHttpSequenceServer([ {body: {data: {...campaign(), operationId: 'op_pause'}, success: true}},