From da2717e891caa47df4ac0361aff3358aa793a1d8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 1 Oct 2026 23:51:44 +0300 Subject: [PATCH] feat(cli): cloud group commands for Official groups (beta); pin SDK 0.1.0-dev.20261001204803 --- README.md | 23 ++- package-lock.json | 8 +- package.json | 8 +- src/commands/cloud/group/create.ts | 49 ++++++ src/commands/cloud/group/delete.ts | 47 ++++++ src/commands/cloud/group/get.ts | 32 ++++ src/commands/cloud/group/invite-link.ts | 50 ++++++ .../cloud/group/join-requests/approve.ts | 43 +++++ .../cloud/group/join-requests/list.ts | 43 +++++ .../cloud/group/join-requests/reject.ts | 43 +++++ src/commands/cloud/group/list.ts | 40 +++++ src/commands/cloud/group/pin.ts | 61 +++++++ .../cloud/group/remove-participants.ts | 44 +++++ src/commands/cloud/group/update.ts | 53 ++++++ test/commands/cloud-group.test.ts | 157 ++++++++++++++++++ test/release/check-prerelease.test.ts | 2 +- 16 files changed, 696 insertions(+), 7 deletions(-) create mode 100644 src/commands/cloud/group/create.ts create mode 100644 src/commands/cloud/group/delete.ts create mode 100644 src/commands/cloud/group/get.ts create mode 100644 src/commands/cloud/group/invite-link.ts create mode 100644 src/commands/cloud/group/join-requests/approve.ts create mode 100644 src/commands/cloud/group/join-requests/list.ts create mode 100644 src/commands/cloud/group/join-requests/reject.ts create mode 100644 src/commands/cloud/group/list.ts create mode 100644 src/commands/cloud/group/pin.ts create mode 100644 src/commands/cloud/group/remove-participants.ts create mode 100644 src/commands/cloud/group/update.ts create mode 100644 test/commands/cloud-group.test.ts diff --git a/README.md b/README.md index 57a80cf..4f34f34 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ prerelease in `package.json`. The version output includes both runtime components: ```text -polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261001202146 +polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261001204803 ``` The SDK component reports the version built into the installed SDK. Dev @@ -477,6 +477,27 @@ polymorfa cloud credentials get --session number_123 polymorfa cloud credentials reauthorize --session number_123 ``` +`cloud group` commands (beta) manage WhatsApp groups on an Official API +Number. They need team enrollment and a Number whose WhatsApp Business Account +is an Official Business Account; otherwise the API returns +`whatsapp_groups_ineligible`. People join with the invite link and cannot be +added; a group holds at most 8 participants besides your business. `create` +returns a `requestId`; the group's conversation ID and invite link arrive in a +`group.update` event. Every change makes one network attempt; pass +`--idempotency-key` so a manual retry after an unknown outcome is answered by +the API instead of changing the group again. + +```bash +polymorfa cloud group create --session number_123 --subject "Order 1042" --join-approval --idempotency-key grp-1042 +polymorfa cloud group list --session number_123 +polymorfa cloud group get GROUP_ID --session number_123 +polymorfa cloud group invite-link GROUP_ID --session number_123 +polymorfa cloud group join-requests list GROUP_ID --session number_123 +polymorfa cloud group join-requests approve GROUP_ID --session number_123 --request JOIN_REQUEST_ID +polymorfa cloud group pin GROUP_ID --session number_123 --message MESSAGE_ID --days 7 +polymorfa cloud group delete GROUP_ID --session number_123 --yes +``` + Template commands use `messages:write` and the connected Meta business template catalog. They are separate from project template drafts. Creation reads a JSON object with `name`, `language`, `category`, and `components` from `--input`, or diff --git a/package-lock.json b/package-lock.json index 3bf359e..b6e3486 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261001202146", + "@polymorfa/sdk": "0.1.0-dev.20261001204803", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20261001202146", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261001202146.tgz", - "integrity": "sha512-ACifZOk4L9+fWU+MwRV6iu2SGCgmXN7vL6DUREab/GkJkmKD9yGQLEmOqaueq0ZyS8Kr0A1ojYGz6G2tBH0RiA==", + "version": "0.1.0-dev.20261001204803", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261001204803.tgz", + "integrity": "sha512-HrIwEvctKgAt8thGdDZnWzurPGdCjGRgaTYqwCLGrR5gAcF9T44cPnn2NW7dfd+aD0rhYfv0XYjkZ1/IbiYUKw==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 2992c8e..4271ab2 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261001202146", + "@polymorfa/sdk": "0.1.0-dev.20261001204803", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -314,6 +314,12 @@ }, "cloud:catalog": { "description": "Read Meta product catalogs visible to a WhatsApp Business Account." + }, + "cloud:group": { + "description": "Manage WhatsApp groups on an Official API Number (beta). Requires an Official Business Account." + }, + "cloud:group:join-requests": { + "description": "Review join requests for Official groups that require approval." } } }, diff --git a/src/commands/cloud/group/create.ts b/src/commands/cloud/group/create.ts new file mode 100644 index 0000000..ea3d454 --- /dev/null +++ b/src/commands/cloud/group/create.ts @@ -0,0 +1,49 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' + +export default class CloudGroupCreate extends BaseCommand { + static override description = + 'Ask WhatsApp to create an Official group (beta). Requires groups:manage, team enrollment and an Official Business Account. Sends once; the group ID and invite link arrive in a group.update event carrying the returned requestId.' + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + subject: Flags.string({ + description: 'Group subject, 1 to 128 characters.', + required: true, + }), + description: Flags.string({ + description: 'Group description, at most 2048 characters.', + }), + 'join-approval': Flags.boolean({ + description: 'Require approval for people who open the invite link.', + default: false, + }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudGroupCreate) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.create( + flags.session, + { + subject: flags.subject, + ...(flags.description === undefined + ? {} + : {description: flags.description}), + ...(flags['join-approval'] ? {joinApprovalRequired: true} : {}), + }, + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/delete.ts b/src/commands/cloud/group/delete.ts new file mode 100644 index 0000000..e036a32 --- /dev/null +++ b/src/commands/cloud/group/delete.ts @@ -0,0 +1,47 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +export default class CloudGroupDelete extends BaseCommand { + static override description = + 'Delete an Official group and remove every participant, including your business (beta). Requires groups:manage. Does not retry an uncertain write.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm deleting the group for every participant.', + required: true, + }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupDelete) + if (!flags.yes) + throw new PolymorfaConfigurationError( + 'Pass --yes to delete the group.', + 'yes', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.delete( + flags.session, + args.group, + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/get.ts b/src/commands/cloud/group/get.ts new file mode 100644 index 0000000..df539b0 --- /dev/null +++ b/src/commands/cloud/group/get.ts @@ -0,0 +1,32 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' + +export default class CloudGroupGet extends BaseCommand { + static override description = + 'Read an Official group: subject, description, participants and settings (beta). Requires groups:read.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupGet) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.retrieve( + flags.session, + args.group, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/invite-link.ts b/src/commands/cloud/group/invite-link.ts new file mode 100644 index 0000000..65aa1ee --- /dev/null +++ b/src/commands/cloud/group/invite-link.ts @@ -0,0 +1,50 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +export default class CloudGroupInviteLink extends BaseCommand { + static override description = + 'Show an Official group invite link, or create a new one with --reset (beta). Resetting stops every earlier link. Requires groups:read, or groups:manage with --reset.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + reset: Flags.boolean({ + description: + 'Create a new link; every earlier link stops working. Sends once.', + default: false, + }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupInviteLink) + if (!flags.reset && flags['idempotency-key'] !== undefined) + throw new PolymorfaConfigurationError( + '--idempotency-key applies only with --reset.', + 'idempotency-key', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = flags.reset + ? await client.officialGroups.resetInviteLink( + flags.session, + args.group, + requestKey(flags['idempotency-key']), + ) + : await client.officialGroups.getInviteLink(flags.session, args.group) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/join-requests/approve.ts b/src/commands/cloud/group/join-requests/approve.ts new file mode 100644 index 0000000..96aafbb --- /dev/null +++ b/src/commands/cloud/group/join-requests/approve.ts @@ -0,0 +1,43 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../../base-command.js' +import {cloudResult, requestKey} from '../../../../services/cloud.js' + +export default class CloudGroupJoinRequestsApprove extends BaseCommand { + static override description = + 'Approve open join requests for an Official group (beta). Requires groups:manage. Sends once; requests WhatsApp could not approve are listed in failed.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + request: Flags.string({ + description: 'Join request ID. Repeat for several.', + required: true, + multiple: true, + }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupJoinRequestsApprove) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.approveJoinRequests( + flags.session, + args.group, + flags.request, + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/join-requests/list.ts b/src/commands/cloud/group/join-requests/list.ts new file mode 100644 index 0000000..2807f3c --- /dev/null +++ b/src/commands/cloud/group/join-requests/list.ts @@ -0,0 +1,43 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../../base-command.js' +import {cloudResult} from '../../../../services/cloud.js' + +export default class CloudGroupJoinRequestsList extends BaseCommand { + static override description = + 'List open join requests for an Official group that requires approval (beta). Requires groups:read.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + before: Flags.string({ + description: 'Cursor from a previous page (cursors.before).', + exclusive: ['after'], + }), + after: Flags.string({ + description: 'Cursor from a previous page (cursors.after).', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupJoinRequestsList) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.listJoinRequests( + flags.session, + args.group, + { + ...(flags.before === undefined ? {} : {before: flags.before}), + ...(flags.after === undefined ? {} : {after: flags.after}), + }, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/join-requests/reject.ts b/src/commands/cloud/group/join-requests/reject.ts new file mode 100644 index 0000000..45f7c17 --- /dev/null +++ b/src/commands/cloud/group/join-requests/reject.ts @@ -0,0 +1,43 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../../base-command.js' +import {cloudResult, requestKey} from '../../../../services/cloud.js' + +export default class CloudGroupJoinRequestsReject extends BaseCommand { + static override description = + 'Reject open join requests for an Official group (beta). Requires groups:manage. Sends once; requests WhatsApp could not reject are listed in failed.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + request: Flags.string({ + description: 'Join request ID. Repeat for several.', + required: true, + multiple: true, + }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupJoinRequestsReject) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.rejectJoinRequests( + flags.session, + args.group, + flags.request, + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/list.ts b/src/commands/cloud/group/list.ts new file mode 100644 index 0000000..5113087 --- /dev/null +++ b/src/commands/cloud/group/list.ts @@ -0,0 +1,40 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' + +export default class CloudGroupList extends BaseCommand { + static override description = + 'List Official groups on an Official API Number (beta). Requires groups:read and team enrollment.' + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + limit: Flags.integer({ + description: 'Groups per page, 1 to 1024.', + min: 1, + max: 1024, + }), + before: Flags.string({ + description: 'Cursor from a previous page (cursors.before).', + exclusive: ['after'], + }), + after: Flags.string({ + description: 'Cursor from a previous page (cursors.after).', + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudGroupList) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.list(flags.session, { + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.before === undefined ? {} : {before: flags.before}), + ...(flags.after === undefined ? {} : {after: flags.after}), + }) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/pin.ts b/src/commands/cloud/group/pin.ts new file mode 100644 index 0000000..cb2b1bc --- /dev/null +++ b/src/commands/cloud/group/pin.ts @@ -0,0 +1,61 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +export default class CloudGroupPin extends BaseCommand { + static override description = + 'Pin an Official group message for 1 to 30 days, or unpin it with --unpin (beta). At most three messages stay pinned. Requires groups:manage. Sends once.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + message: Flags.string({ + description: 'ID of a message in this group.', + required: true, + }), + days: Flags.integer({ + description: 'Pin duration, 1 to 30 days.', + min: 1, + max: 30, + exclusive: ['unpin'], + }), + unpin: Flags.boolean({description: 'Unpin the message.', default: false}), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupPin) + if (!flags.unpin && flags.days === undefined) + throw new PolymorfaConfigurationError( + 'Pass --days to pin, or --unpin.', + 'days', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.pin( + flags.session, + args.group, + flags.unpin + ? {operation: 'unpin', messageId: flags.message} + : { + operation: 'pin', + messageId: flags.message, + expirationDays: flags.days!, + }, + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/remove-participants.ts b/src/commands/cloud/group/remove-participants.ts new file mode 100644 index 0000000..a9a1054 --- /dev/null +++ b/src/commands/cloud/group/remove-participants.ts @@ -0,0 +1,44 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' + +export default class CloudGroupRemoveParticipants extends BaseCommand { + static override description = + 'Remove up to eight participants from an Official group (beta). A removed participant cannot rejoin with the invite link. Requires groups:manage. Sends once.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + participant: Flags.string({ + description: + 'Conversation ID or E.164 phone number. Repeat up to eight times.', + required: true, + multiple: true, + }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupRemoveParticipants) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.removeParticipants( + flags.session, + args.group, + flags.participant, + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/update.ts b/src/commands/cloud/group/update.ts new file mode 100644 index 0000000..faa3132 --- /dev/null +++ b/src/commands/cloud/group/update.ts @@ -0,0 +1,53 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +export default class CloudGroupUpdate extends BaseCommand { + static override description = + 'Change an Official group subject or description (beta). Requires groups:manage. Sends once; a group.update event reports what WhatsApp applied.' + static override args = { + group: Args.string({ + description: 'Conversation ID of the group.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + subject: Flags.string({description: 'New subject, 1 to 128 characters.'}), + description: Flags.string({ + description: 'New description, at most 2048 characters.', + }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API answers instead of changing the group again.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupUpdate) + if (flags.subject === undefined && flags.description === undefined) + throw new PolymorfaConfigurationError( + 'Pass --subject or --description.', + 'subject', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.update( + flags.session, + args.group, + { + ...(flags.subject === undefined ? {} : {subject: flags.subject}), + ...(flags.description === undefined + ? {} + : {description: flags.description}), + }, + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/test/commands/cloud-group.test.ts b/test/commands/cloud-group.test.ts new file mode 100644 index 0000000..d20b8e4 --- /dev/null +++ b/test/commands/cloud-group.test.ts @@ -0,0 +1,157 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +describe('Official group commands (beta)', () => { + let server: TestHttpServer | undefined + afterEach(async () => { + await server?.close() + server = undefined + }) + + async function run(command: string, kind: 'api' | 'client' = 'api') { + if (!server) throw Error('Server is not started') + return withEnvironment( + { + POLYMORFA_API_KEY: + kind === 'api' ? `pmfa_${'A'.repeat(72)}` : undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: + kind === 'client' ? `pmfa_ct_${'A'.repeat(94)}` : undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(command), + ) + } + + test('maps every command to its documented route and body', async () => { + server = await startHttpServer({body: {success: true, data: {}}}) + const base = '/messaging/number%2Feu/official-groups' + const cases: Array<[string, string, string, unknown?]> = [ + [ + 'cloud group list --session number/eu --limit 10 --after QUZ', + 'GET', + `${base}?limit=10&after=QUZ`, + ], + [ + 'cloud group create --session number/eu --subject Orders --join-approval', + 'POST', + base, + {subject: 'Orders', joinApprovalRequired: true}, + ], + [ + 'cloud group get 739182640518203 --session number/eu', + 'GET', + `${base}/739182640518203`, + ], + [ + 'cloud group update 739182640518203 --session number/eu --description Hi', + 'PATCH', + `${base}/739182640518203`, + {description: 'Hi'}, + ], + [ + 'cloud group delete 739182640518203 --session number/eu --yes', + 'DELETE', + `${base}/739182640518203`, + ], + [ + 'cloud group invite-link 739182640518203 --session number/eu', + 'GET', + `${base}/739182640518203/invite-link`, + ], + [ + 'cloud group invite-link 739182640518203 --session number/eu --reset', + 'POST', + `${base}/739182640518203/invite-link/reset`, + ], + [ + 'cloud group remove-participants 739182640518203 --session number/eu --participant +15550001111 --participant 123', + 'POST', + `${base}/739182640518203/participants/remove`, + {participants: ['+15550001111', '123']}, + ], + [ + 'cloud group join-requests list 739182640518203 --session number/eu', + 'GET', + `${base}/739182640518203/join-requests`, + ], + [ + 'cloud group join-requests approve 739182640518203 --session number/eu --request MTY0', + 'POST', + `${base}/739182640518203/join-requests/approve`, + {joinRequestIds: ['MTY0']}, + ], + [ + 'cloud group join-requests reject 739182640518203 --session number/eu --request MTY0', + 'POST', + `${base}/739182640518203/join-requests/reject`, + {joinRequestIds: ['MTY0']}, + ], + [ + 'cloud group pin 739182640518203 --session number/eu --message 42 --days 7', + 'POST', + `${base}/739182640518203/pins`, + {operation: 'pin', messageId: '42', expirationDays: 7}, + ], + [ + 'cloud group pin 739182640518203 --session number/eu --message 42 --unpin', + 'POST', + `${base}/739182640518203/pins`, + {operation: 'unpin', messageId: '42'}, + ], + ] + for (const [command, method, url, body] of cases) { + const before = server.requests.length + const result = await run(`${command} --json`) + expect(result.error, command).toBeUndefined() + const request = server.requests[before]! + expect(`${request.method} ${request.url}`, command).toBe( + `${method} ${url}`, + ) + if (body !== undefined) + expect(JSON.parse(request.body), command).toEqual(body) + } + }) + + test('sends a change once with the idempotency key and reports the eligibility error', async () => { + server = await startHttpServer({ + status: 403, + body: { + error: { + type: 'permission_error', + code: 'whatsapp_groups_ineligible', + message: 'Groups require an Official Business Account.', + }, + }, + }) + const result = await run( + 'cloud group create --session number --subject Orders --idempotency-key grp-1 --json', + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]!.headers['idempotency-key']).toBe('grp-1') + expect(`${result.stdout}${result.stderr}${String(result.error)}`).toContain( + 'whatsapp_groups_ineligible', + ) + }) + + test('refuses invalid local input and client tokens before any request', async () => { + server = await startHttpServer({body: {success: true, data: {}}}) + for (const command of [ + 'cloud group update 1 --session number', + 'cloud group pin 1 --session number --message 42', + 'cloud group invite-link 1 --session number --idempotency-key k', + ]) { + expect((await run(command)).error, command).toBeDefined() + } + expect( + (await run('cloud group list --session number', 'client')).error, + ).toBeDefined() + expect(server.requests).toHaveLength(0) + }) +}) diff --git a/test/release/check-prerelease.test.ts b/test/release/check-prerelease.test.ts index f942b61..5b091a3 100644 --- a/test/release/check-prerelease.test.ts +++ b/test/release/check-prerelease.test.ts @@ -56,7 +56,7 @@ describe('npm prerelease guard', () => { test('accepts the reviewed manifest and lockfile', () => { const result = runWith() expect(result.status).toBe(0) - expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20261001202146') + expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20261001204803') }) test('rejects the default latest tag', () => {