From f05457a02f4d5f69603bff9d836c3a7c70d0eaad Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 2 Oct 2026 16:39:18 +0300 Subject: [PATCH 1/4] =?UTF-8?q?[1]=20chore(deps):=20@norbix.ai/ts=20^4.2.0?= =?UTF-8?q?=20=E2=80=94=20the=20published=20release=20with=20every=20Sms?= =?UTF-8?q?=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI builds on the published TypeScript SDK; 4.2.0 ships all 35 Sms methods (the preview method is scoped 'optional', the transport resolves route tokens case-insensitively). One test pinned 2.1.0's refusal of a push preview with no login; it now checks the request goes out with no Authorization header. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 8 ++++---- package.json | 2 +- tests/push.test.ts | 16 +++++++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 593314c..66f4257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.7.2", - "@norbix.ai/ts": "^2.1.0", + "@norbix.ai/ts": "^4.2.0", "@oclif/core": "^5.0.0", "@oclif/plugin-autocomplete": "^4.0.0", "@oclif/plugin-help": "^7.0.0" @@ -860,9 +860,9 @@ } }, "node_modules/@norbix.ai/ts": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-2.1.0.tgz", - "integrity": "sha512-52PaXvdHn12L6fqteYPMY4K8RlGk+rT7bgf4qEdjIUcmQZmXNf9LcvXIUwpZC6ZlRbSRu7uzOymOm7FIDzVgMQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.2.0.tgz", + "integrity": "sha512-MfaD2mJ+neNqWGXiNp30T3EhA6A5BVtRoOXm1vhTApQ4c8be7MNJG140XQ8WD6wR1PgUs1vRRyQGcvMGSYaWNw==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 4c1de79..876524c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.7.2", - "@norbix.ai/ts": "^2.1.0", + "@norbix.ai/ts": "^4.2.0", "@oclif/core": "^5.0.0", "@oclif/plugin-autocomplete": "^4.0.0", "@oclif/plugin-help": "^7.0.0" diff --git a/tests/push.test.ts b/tests/push.test.ts index 066333f..351acf8 100644 --- a/tests/push.test.ts +++ b/tests/push.test.ts @@ -105,16 +105,18 @@ describe('push preview — signed link, no login, no project', () => { }) /** - * Pins today's behaviour with @norbix.ai/ts 2.1.0: its preview method is - * still scoped 'project', so the SDK itself refuses before sending. Once a - * release with the 'optional' scope is installed, this request goes out with - * no Authorization header — flip this test to expect exactly that. + * With @norbix.ai/ts 4.2.0 the preview method is scoped 'optional', so a call + * with no login goes out with no Authorization header — the signed link is + * the key. (2.1.0 refused it with NORBIX_NOT_AUTHENTICATED; this test pinned + * that until the SDK was bumped.) */ - it('today the installed SDK still refuses a call with no token', async () => { + it('sends the request with no Authorization header when there is no login', async () => { const {error} = await runOclif(['push', 'preview', '--hash', 'abc.def', '--region', 'nb-eu-germany']) - expect(error?.message).toMatch(/NORBIX_NOT_AUTHENTICATED/) - expect(sent).toHaveLength(0) + expect(error).toBeUndefined() + expect(sent).toHaveLength(1) + expect(sent[0]!.url.pathname).toBe('/v2/notifications/push/preview') + expect(sent[0]!.headers.get('Authorization')).toBeNull() }) }) From ea90efdb2c7c00f9355f14419bb1521622bbcd2c Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 2 Oct 2026 16:51:19 +0300 Subject: [PATCH 2/4] =?UTF-8?q?[4]=20feat(sms):=20a=20command=20for=20ever?= =?UTF-8?q?y=20SMS=20endpoint=20=E2=80=94=20module,=20integrations,=20temp?= =?UTF-8?q?late=20write,=20campaign=20create=20and=20reads,=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 new commands next to the 9 that existed, shaped like their push twins: sms enable / disable / disable-dependencies / settings; sms integrations, integration , integration save / enable / disable / default / delete / test / confirm-delivery; sms template create / update / render / tokens; sms campaign create / delete / batches / batch / messages / message; sms preview (signed link, no login needed). `sms campaign create` is not a copy of the push one: the Sms request is flat — `deliveryType` plus exactly one settings block (allUsers / specifiedUsers / collection / phoneNumbers), with `--phone` for raw numbers and `--respect-time-zone` for the per-recipient schedule. `sms stop` calls the SDK method directly; the older-SDK guard is dead since the CLI requires @norbix.ai/ts ^4.2.0. Co-Authored-By: Claude Fable 5.1 --- src/commands/sms/campaign/batch.ts | 47 +++++++++ src/commands/sms/campaign/batches.ts | 32 ++++++ src/commands/sms/campaign/create.ts | 98 +++++++++++++++++++ src/commands/sms/campaign/delete.ts | 33 +++++++ src/commands/sms/campaign/message.ts | 34 +++++++ src/commands/sms/campaign/messages.ts | 34 +++++++ src/commands/sms/disable-dependencies.ts | 17 ++++ src/commands/sms/disable.ts | 31 ++++++ src/commands/sms/enable.ts | 17 ++++ src/commands/sms/integration.ts | 23 +++++ .../sms/integration/confirm-delivery.ts | 26 +++++ src/commands/sms/integration/default.ts | 25 +++++ src/commands/sms/integration/delete.ts | 33 +++++++ src/commands/sms/integration/disable.ts | 25 +++++ src/commands/sms/integration/enable.ts | 25 +++++ src/commands/sms/integration/save.ts | 53 ++++++++++ src/commands/sms/integration/test.ts | 30 ++++++ src/commands/sms/integrations.ts | 27 +++++ src/commands/sms/preview.ts | 56 +++++++++++ src/commands/sms/settings.ts | 23 +++++ src/commands/sms/stop.ts | 14 +-- src/commands/sms/template/create.ts | 28 ++++++ src/commands/sms/template/render.ts | 33 +++++++ src/commands/sms/template/tokens.ts | 23 +++++ src/commands/sms/template/update.ts | 33 +++++++ src/lib/sms.ts | 92 +++++++++++++++++ 26 files changed, 901 insertions(+), 11 deletions(-) create mode 100644 src/commands/sms/campaign/batch.ts create mode 100644 src/commands/sms/campaign/batches.ts create mode 100644 src/commands/sms/campaign/create.ts create mode 100644 src/commands/sms/campaign/delete.ts create mode 100644 src/commands/sms/campaign/message.ts create mode 100644 src/commands/sms/campaign/messages.ts create mode 100644 src/commands/sms/disable-dependencies.ts create mode 100644 src/commands/sms/disable.ts create mode 100644 src/commands/sms/enable.ts create mode 100644 src/commands/sms/integration.ts create mode 100644 src/commands/sms/integration/confirm-delivery.ts create mode 100644 src/commands/sms/integration/default.ts create mode 100644 src/commands/sms/integration/delete.ts create mode 100644 src/commands/sms/integration/disable.ts create mode 100644 src/commands/sms/integration/enable.ts create mode 100644 src/commands/sms/integration/save.ts create mode 100644 src/commands/sms/integration/test.ts create mode 100644 src/commands/sms/integrations.ts create mode 100644 src/commands/sms/preview.ts create mode 100644 src/commands/sms/settings.ts create mode 100644 src/commands/sms/template/create.ts create mode 100644 src/commands/sms/template/render.ts create mode 100644 src/commands/sms/template/tokens.ts create mode 100644 src/commands/sms/template/update.ts create mode 100644 src/lib/sms.ts diff --git a/src/commands/sms/campaign/batch.ts b/src/commands/sms/campaign/batch.ts new file mode 100644 index 0000000..1d581f6 --- /dev/null +++ b/src/commands/sms/campaign/batch.ts @@ -0,0 +1,47 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsCampaignBatch extends BaseCommand { + static description = `List the notifications in one batch — or show one of them + +Give a notification ID as the third argument to see just that notification.` + + static examples = [ + '<%= config.bin %> sms campaign batch 66b2f0a1... b7c3...', + '<%= config.bin %> sms campaign batch 66b2f0a1... b7c3... n9d4...', + ] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + batchId: Args.string({required: true, description: 'Batch ID (from `sms campaign batches`)'}), + notificationId: Args.string({description: 'Notification ID'}), + } + + static flags = { + 'page-size': Flags.integer({description: 'Items per page'}), + after: Flags.string({description: 'Cursor from the previous page'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsCampaignBatch) + const client = this.client(flags) + const n = client.hub.notifications + + const res = args.notificationId + ? await n.getSmsCampaignBatchNotification({ + id: args.id, + batchId: args.batchId, + notificationId: args.notificationId, + }) + : await n.getSmsCampaignBatchNotifications({ + id: args.id, + batchId: args.batchId, + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/campaign/batches.ts b/src/commands/sms/campaign/batches.ts new file mode 100644 index 0000000..7d1b3dd --- /dev/null +++ b/src/commands/sms/campaign/batches.ts @@ -0,0 +1,32 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsCampaignBatches extends BaseCommand { + static description = 'List the send batches of an SMS campaign' + + static examples = ['<%= config.bin %> sms campaign batches 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + } + + static flags = { + 'page-size': Flags.integer({description: 'Items per page'}), + after: Flags.string({description: 'Cursor from the previous page'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsCampaignBatches) + const client = this.client(flags) + + const res = await client.hub.notifications.getSmsCampaignBatches({ + id: args.id, + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/campaign/create.ts b/src/commands/sms/campaign/create.ts new file mode 100644 index 0000000..bd4c9d9 --- /dev/null +++ b/src/commands/sms/campaign/create.ts @@ -0,0 +1,98 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {parseTokens, readJsonObject, SMS_AUDIENCES, TIME_ZONE_RULES, toUnixSeconds} from '../../../lib/sms.js' + +export default class SmsCampaignCreate extends BaseCommand { + static description = `Create an SMS campaign + +--audience picks who receives it: + all-users every user with a phone; narrow with --role / --tag + users the users named with --user + collection the records of --schema whose --field holds the recipient + phone-numbers raw phone numbers, --phone +37060000000 (international format) + +Without --at the campaign is sent right away. Anything the flags do not cover +goes in --config as a JSON object and is merged into the delivery settings.` + + static examples = [ + '<%= config.bin %> sms campaign create --template 66b2f0a1... --audience all-users --tag beta', + '<%= config.bin %> sms campaign create --template 66b2f0a1... --audience users --user usr_1 --user usr_2', + '<%= config.bin %> sms campaign create --template 66b2f0a1... --audience phone-numbers --phone +37060000000 --at 2026-10-01T09:00:00Z', + ] + + static flags = { + template: Flags.string({required: true, description: 'Template ID'}), + audience: Flags.string({required: true, description: 'Who receives it', options: Object.keys(SMS_AUDIENCES)}), + user: Flags.string({description: 'User ID (users; repeat for several)', multiple: true}), + role: Flags.string({description: 'Role name (all-users / collection; repeat for several)', multiple: true}), + tag: Flags.string({description: 'User tag (all-users; repeat for several)', multiple: true}), + schema: Flags.string({description: 'Collection name (collection)'}), + field: Flags.string({description: 'Field that holds the recipient (collection; repeat for several)', multiple: true}), + 'field-type': Flags.string({description: 'What --field holds (collection)', options: ['User', 'Email']}), + phone: Flags.string({description: 'Phone number in international format (phone-numbers; repeat for several)', multiple: true}), + language: Flags.string({description: 'Template language to send'}), + at: Flags.string({description: 'Send later: ISO 8601 date-time or Unix seconds'}), + 'respect-time-zone': Flags.string({ + description: "Send --at in each recipient's own time zone, taken from this source", + options: Object.keys(TIME_ZONE_RULES), + }), + token: Flags.string({description: 'Token value as key=value (repeat for several)', multiple: true}), + 'database-integration': Flags.string({description: 'Database integration ID (defaults to the project default)'}), + config: Flags.string({description: 'Extra delivery-settings fields as a JSON object, @file or -'}), + } + + async run(): Promise { + const {flags} = await this.parse(SmsCampaignCreate) + const client = this.client(flags) + + const audience = SMS_AUDIENCES[flags.audience]! + const extra = flags.config ? await readJsonObject(flags.config, 'config') : {} + const settings: Record = { + ...extra, + recipientsSourceType: audience.deliveryType, + campaignTime: flags.at ? toUnixSeconds(flags.at) : undefined, + respectTimeZoneSettings: flags['respect-time-zone'] ? TIME_ZONE_RULES[flags['respect-time-zone']] : undefined, + mappedTokens: parseTokens(flags.token), + ...this.audienceFields(flags), + } + + // The server reads `deliveryType` and then exactly the matching settings + // block (allUsers / specifiedUsers / collection / phoneNumbers). + const res = await client.hub.notifications.createSmsCampaign({ + templateId: flags.template, + databaseIntegrationId: flags['database-integration'], + language: flags.language, + deliveryType: audience.deliveryType, + [audience.block]: settings, + } as unknown as Parameters[0]) + + this.print(res) + return res + } + + private audienceFields(flags: { + audience: string + user?: string[] + role?: string[] + tag?: string[] + schema?: string + field?: string[] + 'field-type'?: string + phone?: string[] + }): Record { + switch (flags.audience) { + case 'all-users': + return {rolesNames: flags.role, userTags: flags.tag} + case 'users': + if (!flags.user?.length) this.error('--audience users needs at least one --user') + return {recipients: flags.user} + case 'collection': + if (!flags.schema || !flags.field?.length) this.error('--audience collection needs --schema and --field') + return {schemaName: flags.schema, fields: flags.field, fieldType: flags['field-type'], roleNames: flags.role} + default: + if (!flags.phone?.length) this.error('--audience phone-numbers needs at least one --phone') + return {phoneNumbers: flags.phone} + } + } +} diff --git a/src/commands/sms/campaign/delete.ts b/src/commands/sms/campaign/delete.ts new file mode 100644 index 0000000..f3fb30c --- /dev/null +++ b/src/commands/sms/campaign/delete.ts @@ -0,0 +1,33 @@ +import {confirm} from '@inquirer/prompts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsCampaignDelete extends BaseCommand { + static description = 'Delete an SMS campaign' + + static examples = ['<%= config.bin %> sms campaign delete 66b2f0a1... --yes'] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + } + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsCampaignDelete) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: `Delete SMS campaign ${args.id}?`, default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.deleteSmsCampaign({id: args.id}) + + this.print(`Campaign ${args.id} deleted.`) + return res + } +} diff --git a/src/commands/sms/campaign/message.ts b/src/commands/sms/campaign/message.ts new file mode 100644 index 0000000..a6c23b8 --- /dev/null +++ b/src/commands/sms/campaign/message.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsCampaignMessage extends BaseCommand { + static description = 'Show one SMS message a campaign sent' + + static examples = ['<%= config.bin %> sms campaign message 66b2f0a1... n9d4... --batch b7c3...'] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + messageId: Args.string({required: true, description: 'Message (notification) ID'}), + } + + static flags = { + batch: Flags.string({required: true, description: 'Batch the message belongs to'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsCampaignMessage) + const client = this.client(flags) + + // The route is `/campaigns/{campaignId}/messages/{notificationId}` and the + // request type names its fields the same way — no extra `id`, no cast. + const res = await client.hub.notifications.getSmsCampaignMessage({ + campaignId: args.id, + notificationId: args.messageId, + campaignBatchId: flags.batch, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/campaign/messages.ts b/src/commands/sms/campaign/messages.ts new file mode 100644 index 0000000..c23394e --- /dev/null +++ b/src/commands/sms/campaign/messages.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsCampaignMessages extends BaseCommand { + static description = 'List the SMS messages a campaign sent' + + static examples = ['<%= config.bin %> sms campaign messages 66b2f0a1... --batch b7c3...'] + + static args = { + id: Args.string({required: true, description: 'Campaign ID'}), + } + + static flags = { + batch: Flags.string({description: 'Only this batch (from `sms campaign batches`)'}), + 'page-size': Flags.integer({description: 'Items per page'}), + after: Flags.string({description: 'Cursor from the previous page'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsCampaignMessages) + const client = this.client(flags) + + const res = await client.hub.notifications.getSmsCampaignMessages({ + campaignId: args.id, + campaignBatchId: flags.batch, + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/disable-dependencies.ts b/src/commands/sms/disable-dependencies.ts new file mode 100644 index 0000000..c84bbd1 --- /dev/null +++ b/src/commands/sms/disable-dependencies.ts @@ -0,0 +1,17 @@ +import {BaseCommand} from '../../base.js' + +export default class SmsDisableDependencies extends BaseCommand { + static description = 'List what still depends on SMS — check this before `sms disable`' + + static examples = ['<%= config.bin %> sms disable-dependencies'] + + async run(): Promise { + const {flags} = await this.parse(SmsDisableDependencies) + const client = this.client(flags) + + const res = await client.hub.notifications.getSmsDisableDependencies({}) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/disable.ts b/src/commands/sms/disable.ts new file mode 100644 index 0000000..f93354e --- /dev/null +++ b/src/commands/sms/disable.ts @@ -0,0 +1,31 @@ +import {confirm} from '@inquirer/prompts' +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class SmsDisable extends BaseCommand { + static description = `Turn off the SMS module for the project + +Run \`sms disable-dependencies\` first to see what still depends on SMS.` + + static examples = ['<%= config.bin %> sms disable --yes'] + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {flags} = await this.parse(SmsDisable) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: 'Turn off SMS for this project?', default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.disableSms({}) + + this.print('SMS disabled.') + return res + } +} diff --git a/src/commands/sms/enable.ts b/src/commands/sms/enable.ts new file mode 100644 index 0000000..1cd47ad --- /dev/null +++ b/src/commands/sms/enable.ts @@ -0,0 +1,17 @@ +import {BaseCommand} from '../../base.js' + +export default class SmsEnable extends BaseCommand { + static description = 'Turn on the SMS module for the project' + + static examples = ['<%= config.bin %> sms enable'] + + async run(): Promise { + const {flags} = await this.parse(SmsEnable) + const client = this.client(flags) + + const res = await client.hub.notifications.enableSms({}) + + this.print('SMS enabled.') + return res + } +} diff --git a/src/commands/sms/integration.ts b/src/commands/sms/integration.ts new file mode 100644 index 0000000..eca8d35 --- /dev/null +++ b/src/commands/sms/integration.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class SmsIntegration extends BaseCommand { + static description = 'Show one SMS integration' + + static examples = ['<%= config.bin %> sms integration 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsIntegration) + const client = this.client(flags) + + const res = await client.hub.notifications.getSmsIntegration({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/integration/confirm-delivery.ts b/src/commands/sms/integration/confirm-delivery.ts new file mode 100644 index 0000000..12e51d5 --- /dev/null +++ b/src/commands/sms/integration/confirm-delivery.ts @@ -0,0 +1,26 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsIntegrationConfirmDelivery extends BaseCommand { + static description = `Confirm that a test SMS reached a real person + +After \`sms integration test\`, run this once you have seen the SMS on the +phone. It marks the integration as proven to deliver.` + + static examples = ['<%= config.bin %> sms integration confirm-delivery 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsIntegrationConfirmDelivery) + const client = this.client(flags) + + const res = await client.hub.notifications.confirmSmsIntegrationHumanDelivery({integrationId: args.id}) + + this.print(`Delivery confirmed for integration ${args.id}.`) + return res + } +} diff --git a/src/commands/sms/integration/default.ts b/src/commands/sms/integration/default.ts new file mode 100644 index 0000000..b1fa88d --- /dev/null +++ b/src/commands/sms/integration/default.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsIntegrationDefault extends BaseCommand { + static description = 'Make an SMS integration the default one for this project' + + static examples = ['<%= config.bin %> sms integration default 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsIntegrationDefault) + const client = this.client(flags) + + // The route token is `{Id}`; the SDK (4.2.0) fills it from `id` — the + // lookup is case-insensitive — so the generated field is enough. + const res = await client.hub.notifications.setSmsIntegrationAsDefault({id: args.id}) + + this.print(`Integration ${args.id} is now the default.`) + return res + } +} diff --git a/src/commands/sms/integration/delete.ts b/src/commands/sms/integration/delete.ts new file mode 100644 index 0000000..843a052 --- /dev/null +++ b/src/commands/sms/integration/delete.ts @@ -0,0 +1,33 @@ +import {confirm} from '@inquirer/prompts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsIntegrationDelete extends BaseCommand { + static description = 'Delete an SMS integration' + + static examples = ['<%= config.bin %> sms integration delete 66b2f0a1... --yes'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + static flags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsIntegrationDelete) + const client = this.client(flags) + + if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: `Delete SMS integration ${args.id}?`, default: false}) + if (!ok) return this.print('Cancelled.') + } + + const res = await client.hub.notifications.deleteSmsIntegration({id: args.id}) + + this.print(`Integration ${args.id} deleted.`) + return res + } +} diff --git a/src/commands/sms/integration/disable.ts b/src/commands/sms/integration/disable.ts new file mode 100644 index 0000000..e0a30b5 --- /dev/null +++ b/src/commands/sms/integration/disable.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsIntegrationDisable extends BaseCommand { + static description = 'Turn an SMS integration off' + + static examples = ['<%= config.bin %> sms integration disable 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsIntegrationDisable) + const client = this.client(flags) + + // The route token is `{Id}`; the SDK (4.2.0) fills it from `id` — the + // lookup is case-insensitive — so the generated field is enough. + const res = await client.hub.notifications.disableSmsIntegration({id: args.id}) + + this.print(`Integration ${args.id} disabled.`) + return res + } +} diff --git a/src/commands/sms/integration/enable.ts b/src/commands/sms/integration/enable.ts new file mode 100644 index 0000000..afbbb0a --- /dev/null +++ b/src/commands/sms/integration/enable.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsIntegrationEnable extends BaseCommand { + static description = 'Turn an SMS integration on' + + static examples = ['<%= config.bin %> sms integration enable 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Integration ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsIntegrationEnable) + const client = this.client(flags) + + // The route token is `{Id}`; the SDK (4.2.0) fills it from `id` — the + // lookup is case-insensitive — so the generated field is enough. + const res = await client.hub.notifications.enableSmsIntegration({id: args.id}) + + this.print(`Integration ${args.id} enabled.`) + return res + } +} diff --git a/src/commands/sms/integration/save.ts b/src/commands/sms/integration/save.ts new file mode 100644 index 0000000..bb52211 --- /dev/null +++ b/src/commands/sms/integration/save.ts @@ -0,0 +1,53 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {readJsonObject} from '../../../lib/sms.js' + +/** The provider names the server's integration converter accepts. */ +const PROVIDERS = ['Fake', 'Twilio', 'Vonage', 'Plivo', 'Telnyx', 'Bird', 'Telesign', 'Sinch'] + +export default class SmsIntegrationSave extends BaseCommand { + static description = `Create or update an SMS integration + +--provider picks the body shape. Fake needs nothing else: it accepts every send +and contacts no SMS service, so use it while you develop (one Fake per project, +no update — delete and add it again). The real providers need their +credentials — put the provider-specific fields in --config (inline JSON, +@file.json, or - for stdin). Pass --id to update an existing integration.` + + static examples = [ + '<%= config.bin %> sms integration save --provider Fake', + '<%= config.bin %> sms integration save --provider Twilio --name Twilio --config @twilio.json', + ] + + static flags = { + provider: Flags.string({required: true, description: 'SMS provider', options: PROVIDERS}), + name: Flags.string({description: 'Integration name (Fake ignores it and uses its own)'}), + id: Flags.string({description: 'Integration ID — set it to update instead of create'}), + disabled: Flags.boolean({description: 'Save it turned off', default: false}), + config: Flags.string({description: 'Provider-specific fields as a JSON object, @file or -'}), + } + + async run(): Promise { + const {flags} = await this.parse(SmsIntegrationSave) + const client = this.client(flags) + + const extra = flags.config ? await readJsonObject(flags.config, 'config') : {} + const integration = { + ...extra, + provider: flags.provider, + integrationId: flags.id, + integrationName: flags.name ?? '', + isEnabled: !flags.disabled, + } + + // The provider-specific fields are not on the generated base type, so the + // body is cast: the server picks the shape from `provider`. + const res = await client.hub.notifications.saveSmsIntegration({integration} as unknown as Parameters< + typeof client.hub.notifications.saveSmsIntegration + >[0]) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/integration/test.ts b/src/commands/sms/integration/test.ts new file mode 100644 index 0000000..483e571 --- /dev/null +++ b/src/commands/sms/integration/test.ts @@ -0,0 +1,30 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsIntegrationTest extends BaseCommand { + static description = `Send a test SMS through an integration + +Point this at the Fake integration while you are developing — it accepts the +send and contacts no SMS service, so nothing reaches a real phone.` + + static examples = ['<%= config.bin %> sms integration test --integration 66b2f0a1... --to +37060000000'] + + static flags = { + integration: Flags.string({required: true, description: 'Integration ID to send through'}), + to: Flags.string({description: 'Phone number (international format) to send the test to'}), + } + + async run(): Promise { + const {flags} = await this.parse(SmsIntegrationTest) + const client = this.client(flags) + + const res = await client.hub.notifications.testSmsIntegration({ + integrationId: flags.integration, + to: flags.to, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/integrations.ts b/src/commands/sms/integrations.ts new file mode 100644 index 0000000..cb607ff --- /dev/null +++ b/src/commands/sms/integrations.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class SmsIntegrations extends BaseCommand { + static description = 'List the SMS integrations set up for this project' + + static examples = ['<%= config.bin %> sms integrations --page-size 50'] + + static flags = { + 'page-size': Flags.integer({description: 'Records per page'}), + after: Flags.string({description: 'Cursor: fetch the page after this item'}), + } + + async run(): Promise { + const {flags} = await this.parse(SmsIntegrations) + const client = this.client(flags) + + const res = await client.hub.notifications.getSmsIntegrations({ + pageSize: flags['page-size'], + startingAfter: flags.after, + }) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/preview.ts b/src/commands/sms/preview.ts new file mode 100644 index 0000000..6537202 --- /dev/null +++ b/src/commands/sms/preview.ts @@ -0,0 +1,56 @@ +import {NorbixError} from '@norbix.ai/ts' +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class SmsPreview extends BaseCommand { + static description = `Render the text behind an SMS preview link + +The hash is the opaque value the backend puts in a preview link — you cannot +build it yourself, so copy it out of the link. + +The signed link is the key: no login and no project are needed. When you are +logged in, your session is still sent.` + + static examples = [ + '<%= config.bin %> sms preview 8f2a91c4...', + '<%= config.bin %> sms preview --hash 8f2a91c4... --region nb-eu-germany', + ] + + static args = { + hash: Args.string({required: false, description: 'Preview hash from the preview link'}), + } + + static flags = { + hash: Flags.string({description: 'Preview hash from the preview link (same as the argument)'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsPreview) + const hash = flags.hash ?? args.hash + if (!hash) { + this.error('Pass the preview hash: `norbix sms preview ` or `--hash `.') + } + + // The signed link opens without sign-in, so neither a login nor a project + // is forced here. A login, when there is one, is still used. + const client = this.client(flags, {requireAuth: false, requireProject: false}) + + const res = await client.hub.notifications.previewSmsNotification({hash}) + + this.print(res) + return res + } + + protected async catch(error: Error & {exitCode?: number}): Promise { + // A 401 here means the link is bad or expired, not that a session ran out. + if (error instanceof NorbixError && (error as {status?: number}).status === 401) { + const {code} = error as {code?: string} + return this.error( + `${code ? `${code}: ` : ''}${error.message}\nThe preview link is invalid or has expired. Ask for a new link.`, + ) + } + + return super.catch(error) + } +} diff --git a/src/commands/sms/settings.ts b/src/commands/sms/settings.ts new file mode 100644 index 0000000..cc68a21 --- /dev/null +++ b/src/commands/sms/settings.ts @@ -0,0 +1,23 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class SmsSettings extends BaseCommand { + static description = 'Show the project SMS settings' + + static examples = ['<%= config.bin %> sms settings'] + + static flags = { + id: Flags.string({description: 'Settings ID (defaults to the project settings)'}), + } + + async run(): Promise { + const {flags} = await this.parse(SmsSettings) + const client = this.client(flags) + + const res = await client.hub.notifications.getSmsSettings(flags.id ? {id: flags.id} : {}) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/stop.ts b/src/commands/sms/stop.ts index 23698cb..4797f9e 100644 --- a/src/commands/sms/stop.ts +++ b/src/commands/sms/stop.ts @@ -25,17 +25,9 @@ export default class SmsStop extends BaseCommand { if (!ok) return this.print('Cancelled.') } - const n = client.hub.notifications as unknown as { - stopSmsCampaign?: (r: {id: string}) => Promise - } - if (!n.stopSmsCampaign) { - this.error( - 'Your installed @norbix.ai/ts does not support stopping campaigns yet.\n' + - `Update the SDK, or run: norbix api "/{version}/notifications/sms/campaigns/${args.id}/stop" --hub --method POST`, - ) - } - - const res = await n.stopSmsCampaign({id: args.id}) + // The route is `/campaigns/{Id}/stop`; the SDK (4.2.0) fills the token from + // `id` — the lookup is case-insensitive — so the generated field is enough. + const res = await client.hub.notifications.stopSmsCampaign({id: args.id}) this.print(`Campaign ${args.id} stopped.`) return res } diff --git a/src/commands/sms/template/create.ts b/src/commands/sms/template/create.ts new file mode 100644 index 0000000..3674155 --- /dev/null +++ b/src/commands/sms/template/create.ts @@ -0,0 +1,28 @@ +import {BaseCommand} from '../../../base.js' +import {smsTemplateBody, smsTemplateFlags} from '../../../lib/sms.js' + +export default class SmsTemplateCreate extends BaseCommand { + static description = `Create an SMS template + +One language: --body (and an optional --subject, the sender id). Several +languages: --translations with a JSON array of {language, content: {subject, body}}.` + + static examples = [ + '<%= config.bin %> sms template create --name Welcome --body "Hi @Model.Name, your code is @Model.Code"', + '<%= config.bin %> sms template create --name Welcome --translations @welcome.json', + ] + + static flags = smsTemplateFlags + + async run(): Promise { + const {flags} = await this.parse(SmsTemplateCreate) + const client = this.client(flags) + + const res = await client.hub.notifications.createSmsTemplate( + (await smsTemplateBody(flags)) as unknown as Parameters[0], + ) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/template/render.ts b/src/commands/sms/template/render.ts new file mode 100644 index 0000000..43a76b0 --- /dev/null +++ b/src/commands/sms/template/render.ts @@ -0,0 +1,33 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {parseTokens} from '../../../lib/sms.js' + +export default class SmsTemplateRender extends BaseCommand { + static description = `Render SMS text with token values + +Use it to check a Razor template before you save it. When the template needs a +token you did not pass, the answer lists the missing ones.` + + static examples = ['<%= config.bin %> sms template render --code "Hi @Model.Name" --token Name=Ada'] + + static flags = { + code: Flags.string({required: true, description: 'Razor source of the text'}), + token: Flags.string({description: 'Token value as key=value (repeat for several)', multiple: true}), + preview: Flags.boolean({description: 'Render for a preview (less strict)', default: false}), + } + + async run(): Promise { + const {flags} = await this.parse(SmsTemplateRender) + const client = this.client(flags) + + const res = await client.hub.notifications.renderSms({ + code: flags.code, + tokens: parseTokens(flags.token), + isForPreview: flags.preview, + } as unknown as Parameters[0]) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/template/tokens.ts b/src/commands/sms/template/tokens.ts new file mode 100644 index 0000000..ffe52cb --- /dev/null +++ b/src/commands/sms/template/tokens.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class SmsTemplateTokens extends BaseCommand { + static description = 'List the tokens an SMS template uses — the values a campaign must fill' + + static examples = ['<%= config.bin %> sms template tokens 66b2f0a1...'] + + static args = { + id: Args.string({required: true, description: 'Template ID'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(SmsTemplateTokens) + const client = this.client(flags) + + const res = await client.hub.notifications.getSmsMessageContentTokens({id: args.id}) + + this.print(res) + return res + } +} diff --git a/src/commands/sms/template/update.ts b/src/commands/sms/template/update.ts new file mode 100644 index 0000000..58259e6 --- /dev/null +++ b/src/commands/sms/template/update.ts @@ -0,0 +1,33 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {smsTemplateBody, smsTemplateFlags} from '../../../lib/sms.js' + +export default class SmsTemplateUpdate extends BaseCommand { + static description = `Replace an SMS template's name, channel, tags and content + +The whole template is sent, so pass every language you want to keep.` + + static examples = [ + '<%= config.bin %> sms template update 66b2f0a1... --name Welcome --body "Hi @Model.Name"', + ] + + static args = { + id: Args.string({required: true, description: 'Template ID'}), + } + + static flags = smsTemplateFlags + + async run(): Promise { + const {args, flags} = await this.parse(SmsTemplateUpdate) + const client = this.client(flags) + + const res = await client.hub.notifications.updateSmsTemplate({ + ...(await smsTemplateBody(flags)), + viewId: args.id, + } as unknown as Parameters[0]) + + this.print(`Template ${args.id} updated.`) + return res + } +} diff --git a/src/lib/sms.ts b/src/lib/sms.ts new file mode 100644 index 0000000..4d65fae --- /dev/null +++ b/src/lib/sms.ts @@ -0,0 +1,92 @@ +import {readFileSync} from 'node:fs' + +import {Flags} from '@oclif/core' + +import {readJsonInput} from './json.js' + +// `readJsonObject` and `parseTokens` are shared with the push commands; they +// live in ./push.ts and are used here unchanged. +export {parseTokens, readJsonObject} from './push.js' + +/** Flags shared by `sms template create` and `sms template update`. */ +export interface SmsTemplateFlags { + name: string + description?: string + channel: string + tag?: string[] + language: string + subject?: string + body?: string + translations?: string +} + +/** + * Build the template body. Either --translations (a JSON array, @file or -) + * for several languages, or --body (plus an optional --subject) for one. + * + * An SMS translation carries `subject` and `body`; the portal sends an empty + * subject by default (it is the sender id, not a title), so --subject is + * optional here and defaults to ''. + */ +export async function smsTemplateBody(flags: SmsTemplateFlags): Promise> { + let translations: unknown + if (flags.translations) { + const raw = flags.translations.startsWith('@') ? readFileSync(flags.translations.slice(1), 'utf8') : flags.translations + translations = JSON.parse(await readJsonInput(raw, 'translations')) as unknown + if (!Array.isArray(translations)) throw new Error('--translations must be a JSON array') + } else { + if (!flags.body) { + throw new Error('Pass --body, or --translations for several languages.') + } + + translations = [{language: flags.language, content: {subject: flags.subject ?? '', body: flags.body}}] + } + + return { + templateName: flags.name, + description: flags.description, + communicationChannel: flags.channel, + tags: flags.tag, + translations, + } +} + +export const smsTemplateFlags = { + name: Flags.string({required: true, description: 'Template name'}), + description: Flags.string({description: 'What the template is for'}), + channel: Flags.string({ + description: 'Communication channel', + options: ['Transactional', 'Marketing', 'System'], + default: 'Transactional', + }), + tag: Flags.string({description: 'Tag (repeat for several)', multiple: true}), + language: Flags.string({description: 'Language of --body', default: 'en'}), + subject: Flags.string({description: 'Sender id / subject line (Razor allowed; empty by default)'}), + body: Flags.string({description: 'SMS text (Razor allowed)'}), + translations: Flags.string({ + description: 'All languages as a JSON array of {language, content: {subject, body}} — inline, @file or -', + exclusive: ['subject', 'body'], + }), +} + +/** --audience value → the `deliveryType` the server picks the settings block by. */ +export const SMS_AUDIENCES: Record = { + 'all-users': {deliveryType: 'AllUsers', block: 'allUsers'}, + users: {deliveryType: 'SpecifiedUsers', block: 'specifiedUsers'}, + collection: {deliveryType: 'Collection', block: 'collection'}, + 'phone-numbers': {deliveryType: 'PhoneNumbers', block: 'phoneNumbers'}, +} + +/** --respect-time-zone value → the RespectTimeZoneSettings flag the server stores. */ +export const TIME_ZONE_RULES: Record = { + 'last-login': 1, + registration: 2, + 'registration-project': 4, +} + +export function toUnixSeconds(value: string): number { + if (/^\d+$/.test(value)) return Number(value) + const ms = Date.parse(value) + if (Number.isNaN(ms)) throw new Error(`--at is not a date: "${value}"`) + return Math.floor(ms / 1000) +} From 36e1fdf55584472072ec9cc02d59d0e4eb92f8a4 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 2 Oct 2026 16:51:19 +0300 Subject: [PATCH 3/4] [5] test(sms): recorder tests for all 33 sms commands plus a real-transport route test for the 35 routes tests/sms-existing.test.ts covers the 9 commands that shipped with no test; tests/sms.test.ts the new ones (plus the signed-link preview with no login); tests/sms-routes.test.ts runs every command through oclif and the real @norbix.ai/ts transport with fetch replaced, asserting verb + resolved path (35 distinct routes) and the request bodies: Fake integration save, the template translation, and the four campaign audiences on the wire. Co-Authored-By: Claude Fable 5.1 --- tests/sms-existing.test.ts | 92 +++++++++++ tests/sms-routes.test.ts | 317 +++++++++++++++++++++++++++++++++++++ tests/sms.test.ts | 270 +++++++++++++++++++++++++++++++ 3 files changed, 679 insertions(+) create mode 100644 tests/sms-existing.test.ts create mode 100644 tests/sms-routes.test.ts create mode 100644 tests/sms.test.ts diff --git a/tests/sms-existing.test.ts b/tests/sms-existing.test.ts new file mode 100644 index 0000000..51a7705 --- /dev/null +++ b/tests/sms-existing.test.ts @@ -0,0 +1,92 @@ +import {describe, expect, it} from 'vitest' + +import SmsArchive from '../src/commands/sms/archive.js' +import SmsCampaign from '../src/commands/sms/campaign.js' +import SmsCampaigns from '../src/commands/sms/campaigns.js' +import SmsClone from '../src/commands/sms/clone.js' +import SmsDelete from '../src/commands/sms/delete.js' +import SmsStop from '../src/commands/sms/stop.js' +import SmsTemplate from '../src/commands/sms/template.js' +import SmsTemplates from '../src/commands/sms/templates.js' +import SmsUnarchive from '../src/commands/sms/unarchive.js' +import {runCommand} from './_helpers.js' + +/** + * Cover the nine sms commands that shipped before this item (the email twins). + * They had no tests at all. These check which SDK method each one picks and + * which fields it fills; `sms-routes.test.ts` proves the verb and path through + * the real transport. + */ + +const ID = '66b2f0a1' + +describe('sms templates', () => { + it('lists templates, live only by default', async () => { + const {calls} = await runCommand(SmsTemplates, []) + expect(calls).toEqual([{method: 'getSmsTemplates', request: {showArchived: false}}]) + }) + + it('includes archived templates with --archived', async () => { + const {calls} = await runCommand(SmsTemplates, ['--archived']) + expect(calls[0]?.request).toEqual({showArchived: true}) + }) + + it('shows one template', async () => { + const {calls} = await runCommand(SmsTemplate, [ID]) + expect(calls).toEqual([{method: 'getSmsTemplate', request: {id: ID}}]) + }) +}) + +describe('sms template state changes', () => { + // These four routes spell their token `{Id}`; the commands send the generated + // field `id`, which @norbix.ai/ts 4.2.0 matches case-insensitively. The route + // test proves the resolved path; this one pins the method and the field. + const cases = [ + {command: SmsArchive, method: 'archiveSmsTemplate', argv: [ID], output: `Template ${ID} archived.`}, + {command: SmsUnarchive, method: 'unArchiveSmsTemplate', argv: [ID], output: `Template ${ID} restored.`}, + {command: SmsClone, method: 'cloneSmsTemplate', argv: [ID], output: `Template ${ID} cloned.`}, + {command: SmsDelete, method: 'deleteSmsTemplate', argv: [ID, '--yes'], output: `Template ${ID} deleted.`}, + ] + + for (const {command, method, argv, output} of cases) { + it(`${method} sends the template id and says what it did`, async () => { + const result = await runCommand(command, argv) + expect(result.calls).toEqual([{method, request: {id: ID}}]) + expect(result.output).toEqual([output]) + }) + } +}) + +describe('sms campaigns', () => { + it('lists campaigns', async () => { + const {calls} = await runCommand(SmsCampaigns, []) + expect(calls[0]?.method).toBe('getSmsCampaigns') + expect(calls[0]?.request).toEqual({pageSize: undefined, startingAfter: undefined}) + }) + + it('passes page size and cursor', async () => { + const {calls} = await runCommand(SmsCampaigns, ['--page-size', '10', '--after', 'cur_1']) + expect(calls[0]?.request).toEqual({pageSize: 10, startingAfter: 'cur_1'}) + }) + + it('shows one campaign', async () => { + const {calls} = await runCommand(SmsCampaign, [ID]) + expect(calls).toEqual([{method: 'getSmsCampaign', request: {id: ID}}]) + }) + + it('shows statistics instead with --stats', async () => { + const {calls} = await runCommand(SmsCampaign, [ID, '--stats']) + expect(calls).toEqual([{method: 'getSmsCampaignStatistics', request: {id: ID}}]) + }) +}) + +describe('sms stop', () => { + it('stops a campaign through the SDK method', async () => { + // Before this item the command probed for `stopSmsCampaign` and printed a + // `norbix api` fallback for SDKs older than 1.3.0. The CLI now requires + // @norbix.ai/ts ^4.2.0, so it calls the method like `push stop` does. + const {calls, output} = await runCommand(SmsStop, [ID, '--yes']) + expect(calls).toEqual([{method: 'stopSmsCampaign', request: {id: ID}}]) + expect(output).toEqual([`Campaign ${ID} stopped.`]) + }) +}) diff --git a/tests/sms-routes.test.ts b/tests/sms-routes.test.ts new file mode 100644 index 0000000..9785ead --- /dev/null +++ b/tests/sms-routes.test.ts @@ -0,0 +1,317 @@ +import {runCommand} from '@oclif/test' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' + +/** + * One row per SMS endpoint: run the command the way a user types it, through + * oclif and the real `@norbix.ai/ts` transport, and check the verb and path of + * the request it sends. + * + * `sms.test.ts` and `sms-existing.test.ts` swap the SDK for a recorder, which + * cannot see route tokens. Here the request goes all the way to `fetch`, which + * is replaced, so nothing leaves the process and no SMS provider is contacted. + * (The push campaign shipped a `stop` with the wrong token name behind a green + * recorder test — this file is what catches that.) + */ + +const ID = '66b2f0a1' +const BATCH = 'b7c3' +const MESSAGE = 'n9d4' +const PHONE = '+37060000000' + +interface Call { + method: string + path: string + query: URLSearchParams + body?: Record +} + +let calls: Call[] +let realFetch: typeof globalThis.fetch + +beforeEach(() => { + calls = [] + realFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + calls.push({ + method: init?.method ?? 'GET', + path: url.pathname, + query: url.searchParams, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as Record) : undefined, + }) + return new Response('{}', {status: 200, headers: {'Content-Type': 'application/json'}}) + }) as typeof globalThis.fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +const globalArgs = ['--project', 'test-project', '--api-key', 'test-api-key', '--region', 'nb-eu-germany'] + +const P = '/v2/notifications/sms' + +/** [argv, verb, path] — argv without the leading `sms`. */ +const routes: Array<[string[], string, string]> = [ + // Module + [['settings'], 'GET', `${P}/settings`], + [['enable'], 'GET', `${P}/enable`], + [['disable', '--yes'], 'GET', `${P}/disable`], + [['disable-dependencies'], 'GET', `${P}/disable-dependencies`], + + // Integrations + [['integrations'], 'GET', `${P}/integrations`], + [['integration', ID], 'GET', `${P}/integrations/${ID}`], + [['integration', 'enable', ID], 'PUT', `${P}/integrations/${ID}/enable`], + [['integration', 'disable', ID], 'PUT', `${P}/integrations/${ID}/disable`], + [['integration', 'default', ID], 'PUT', `${P}/integrations/${ID}/default`], + [['integration', 'delete', ID, '--yes'], 'DELETE', `${P}/integrations/${ID}`], + [['integration', 'test', '--integration', ID, '--to', PHONE], 'POST', `${P}/integrations/test`], + [['integration', 'save', '--provider', 'Fake'], 'POST', `${P}/integrations`], + [['integration', 'confirm-delivery', ID], 'POST', `${P}/integrations/confirm-human-delivery`], + + // Templates + [['templates'], 'GET', `${P}/templates`], + [['template', ID], 'GET', `${P}/templates/${ID}`], + [['archive', ID], 'PUT', `${P}/templates/${ID}/archive`], + [['unarchive', ID], 'PUT', `${P}/templates/${ID}/unarchive`], + [['clone', ID], 'POST', `${P}/templates/${ID}/clone`], + [['delete', ID, '--yes'], 'DELETE', `${P}/templates/${ID}`], + [['template', 'create', '--name', 'Welcome', '--body', 'Thanks'], 'POST', `${P}/templates`], + [['template', 'update', ID, '--name', 'Welcome', '--body', 'Thanks'], 'PUT', `${P}/templates`], + [['template', 'render', '--code', '@Model.Name', '--token', 'Name=Ada'], 'POST', `${P}/templates/render`], + [['template', 'tokens', ID], 'GET', `${P}/templates/${ID}/tokens`], + + // Campaigns + [['campaigns'], 'GET', `${P}/campaigns`], + [['campaign', ID], 'GET', `${P}/campaigns/${ID}`], + [['campaign', ID, '--stats'], 'GET', `${P}/campaigns/${ID}/stats`], + [['preview', '8f2a91c4'], 'GET', `${P}/preview`], + [['stop', ID, '--yes'], 'POST', `${P}/campaigns/${ID}/stop`], + [['campaign', 'create', '--template', ID, '--audience', 'all-users'], 'POST', `${P}/campaigns`], + [['campaign', 'delete', ID, '--yes'], 'DELETE', `${P}/campaigns/${ID}`], + [['campaign', 'batches', ID], 'GET', `${P}/campaigns/${ID}/batches`], + [['campaign', 'batch', ID, BATCH], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}`], + [['campaign', 'batch', ID, BATCH, MESSAGE], 'GET', `${P}/campaigns/${ID}/batches/${BATCH}/${MESSAGE}`], + [['campaign', 'messages', ID, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/messages`], + [['campaign', 'message', ID, MESSAGE, '--batch', BATCH], 'GET', `${P}/campaigns/${ID}/messages/${MESSAGE}`], +] + +describe('every sms command reaches its route', () => { + for (const [argv, verb, path] of routes) { + it(`sms ${argv.join(' ')} → ${verb} ${path}`, async () => { + const {error} = await runCommand(['sms', ...argv, ...globalArgs]) + + expect(error).toBeUndefined() + expect(calls).toHaveLength(1) + expect(calls[0]?.method).toBe(verb) + expect(calls[0]?.path).toBe(path) + }) + } +}) + +it('covers all 35 sms routes', () => { + // The gateway's Hub.Sms project has 35 [Route] attributes and the manifest + // (sdks/typegen/coverage/endpoints.hub.json) lists 35 Sms entries. The + // campaign `--stats` row and the batch row with a notification id share a + // command with another row, so count distinct verb + path pairs. + expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(35) +}) + +/** Run a command and return the one request body it sent. */ +// Note: @oclif/test's runCommand splits argv on spaces, so no test value here +// contains one. +async function bodyOf(argv: string[]): Promise> { + const {error} = await runCommand(['sms', ...argv, ...globalArgs]) + expect(error).toBeUndefined() + expect(calls).toHaveLength(1) + return calls[0]?.body ?? {} +} + +describe('sms integration save', () => { + it('saves the Fake provider with nothing else needed', async () => { + const body = await bodyOf(['integration', 'save', '--provider', 'Fake']) + // `provider` is the discriminator the server routes the body by; the Fake + // needs no payload (Hub.Sms/Integrations/Save.Fake.cs). + expect(body).toEqual({integration: {provider: 'Fake', integrationName: '', isEnabled: true}}) + }) + + it('merges --config into the integration and keeps the flags on top', async () => { + const body = await bodyOf([ + 'integration', 'save', '--provider', 'Twilio', '--name', 'Twilio', '--id', ID, '--disabled', + '--config', '{"provider":"Fake","accountSid":"AC1"}', + ]) + expect(body).toEqual({ + integration: {provider: 'Twilio', accountSid: 'AC1', integrationId: ID, integrationName: 'Twilio', isEnabled: false}, + }) + }) + + it('rejects a provider the server does not know', async () => { + const {error} = await runCommand(['sms', 'integration', 'save', '--provider', 'Nope', ...globalArgs]) + expect(error?.message).toMatch(/Expected --provider=Nope to be one of/) + expect(calls).toHaveLength(0) + }) +}) + +describe('sms integration test / confirm-delivery', () => { + it('test sends the integration id and the phone number in the body', async () => { + expect(await bodyOf(['integration', 'test', '--integration', ID, '--to', PHONE])).toEqual({integrationId: ID, to: PHONE}) + }) + + it('confirm-delivery sends the integration id in the body', async () => { + expect(await bodyOf(['integration', 'confirm-delivery', ID])).toEqual({integrationId: ID}) + }) +}) + +describe('sms template create / update', () => { + it('builds one translation from --body, with an empty subject like the portal', async () => { + const body = await bodyOf(['template', 'create', '--name', 'Welcome', '--body', 'Thanks', '--tag', 'a', '--tag', 'b']) + expect(body).toEqual({ + templateName: 'Welcome', + communicationChannel: 'Transactional', + tags: ['a', 'b'], + translations: [{language: 'en', content: {subject: '', body: 'Thanks'}}], + }) + }) + + it('puts --subject and --language into the translation', async () => { + const body = await bodyOf(['template', 'create', '--name', 'Welcome', '--subject', 'Norbix', '--body', 'Ačiū', '--language', 'lt']) + expect(body.translations).toEqual([{language: 'lt', content: {subject: 'Norbix', body: 'Ačiū'}}]) + }) + + it('takes several languages from --translations', async () => { + const translations = [ + {language: 'en', content: {subject: '', body: 'Thanks'}}, + {language: 'lt', content: {subject: '', body: 'Ačiū'}}, + ] + const body = await bodyOf(['template', 'create', '--name', 'Welcome', '--translations', JSON.stringify(translations)]) + expect(body.translations).toEqual(translations) + }) + + it('refuses to send a template with no text', async () => { + const {error} = await runCommand(['sms', 'template', 'create', '--name', 'Welcome', ...globalArgs]) + expect(error?.message).toMatch(/--body/) + expect(calls).toHaveLength(0) + }) + + it('sends the template id as viewId on update', async () => { + const body = await bodyOf(['template', 'update', ID, '--name', 'Welcome', '--channel', 'Marketing', '--body', 'Thanks']) + expect(body).toMatchObject({viewId: ID, templateName: 'Welcome', communicationChannel: 'Marketing'}) + }) +}) + +describe('sms template render', () => { + it('sends the code and the tokens as Custom mappings', async () => { + const body = await bodyOf(['template', 'render', '--code', '@Model.Name', '--token', 'Name=Ada', '--preview']) + expect(body).toEqual({ + code: '@Model.Name', + tokens: [{key: 'Name', value: 'Ada', resolver: 'Custom'}], + isForPreview: true, + }) + }) +}) + +describe('sms campaign create — the four audiences', () => { + // The server reads `deliveryType` and then exactly the matching settings + // block (Hub.Sms/Campaigns/Create.cs `Settings` switch), so each audience is + // checked on the wire, not just the flags. This is NOT the push shape + // (`campaign.source`): the Sms request is flat. + const cases: Array<[string, string[], string, Record]> = [ + ['all-users', ['--tag', 'beta', '--role', 'Admin'], 'allUsers', {recipientsSourceType: 'AllUsers', userTags: ['beta'], rolesNames: ['Admin']}], + ['users', ['--user', 'usr_1', '--user', 'usr_2'], 'specifiedUsers', {recipientsSourceType: 'SpecifiedUsers', recipients: ['usr_1', 'usr_2']}], + [ + 'collection', + ['--schema', 'subscribers', '--field', 'owner', '--field-type', 'User', '--role', 'Member'], + 'collection', + {recipientsSourceType: 'Collection', schemaName: 'subscribers', fields: ['owner'], fieldType: 'User', roleNames: ['Member']}, + ], + ['phone-numbers', ['--phone', PHONE, '--phone', '+37060000001'], 'phoneNumbers', {recipientsSourceType: 'PhoneNumbers', phoneNumbers: [PHONE, '+37060000001']}], + ] + + for (const [audience, extra, block, settings] of cases) { + it(`--audience ${audience} → deliveryType ${settings.recipientsSourceType as string} + ${block} block`, async () => { + const body = await bodyOf(['campaign', 'create', '--template', ID, '--audience', audience, ...extra]) + expect(body).toEqual({templateId: ID, deliveryType: settings.recipientsSourceType, [block]: settings}) + }) + } + + it('passes schedule, time-zone rule, tokens, language and the database integration', async () => { + const body = await bodyOf([ + 'campaign', 'create', '--template', ID, '--audience', 'all-users', '--language', 'lt', + '--at', '2026-09-20T12:00:00Z', '--respect-time-zone', 'registration', '--token', 'Code=42', + '--database-integration', 'db_1', + ]) + expect(body).toEqual({ + templateId: ID, + databaseIntegrationId: 'db_1', + language: 'lt', + deliveryType: 'AllUsers', + allUsers: { + recipientsSourceType: 'AllUsers', + campaignTime: 1789905600, + respectTimeZoneSettings: 2, + mappedTokens: [{key: 'Code', value: '42', resolver: 'Custom'}], + }, + }) + }) + + it('takes Unix seconds for --at as they are', async () => { + const body = await bodyOf(['campaign', 'create', '--template', ID, '--audience', 'phone-numbers', '--phone', PHONE, '--at', '1789905600']) + expect((body.phoneNumbers as Record).campaignTime).toBe(1789905600) + }) + + it('merges --config into the settings block and keeps the flags on top', async () => { + const body = await bodyOf([ + 'campaign', 'create', '--template', ID, '--audience', 'users', '--user', 'usr_1', + '--config', '{"recipients":["ignored"],"note":"x"}', + ]) + expect(body.specifiedUsers).toEqual({recipientsSourceType: 'SpecifiedUsers', recipients: ['usr_1'], note: 'x'}) + }) + + const missing: Array<[string, string[], RegExp]> = [ + ['users', [], /needs at least one --user/], + ['collection', ['--schema', 's'], /needs --schema and --field/], + ['phone-numbers', [], /needs at least one --phone/], + ] + for (const [audience, extra, message] of missing) { + it(`--audience ${audience} ${extra.join(' ')} fails before sending`, async () => { + const {error} = await runCommand(['sms', 'campaign', 'create', '--template', ID, '--audience', audience, ...extra, ...globalArgs]) + expect(error?.message).toMatch(message) + expect(calls).toHaveLength(0) + }) + } + + it('refuses an audience the server does not have a settings block for', async () => { + // The SmsCampaignRecipientsSourceTypes enum also lists AccountUsers, but + // CreateSmsCampaignRequest has no `accountUsers` block, so the server would + // answer IntegrationTypeNotSupportedError. The CLI does not offer it. + const {error} = await runCommand(['sms', 'campaign', 'create', '--template', ID, '--audience', 'account-users', ...globalArgs]) + expect(error?.message).toMatch(/Expected --audience=account-users to be one of/) + expect(calls).toHaveLength(0) + }) +}) + +describe('sms campaign reads', () => { + it('message sends the batch as a query field, and the message id fills the route token', async () => { + await bodyOf(['campaign', 'message', ID, MESSAGE, '--batch', BATCH]) + expect(calls[0]?.path).toBe(`${P}/campaigns/${ID}/messages/${MESSAGE}`) + expect(calls[0]?.query.get('campaignBatchId')).toBe(BATCH) + expect(calls[0]?.query.get('notificationId')).toBeNull() + }) + + it('batches pass paging', async () => { + await bodyOf(['campaign', 'batches', ID, '--page-size', '10', '--after', 'cur_1']) + expect(calls[0]?.query.get('pageSize')).toBe('10') + expect(calls[0]?.query.get('startingAfter')).toBe('cur_1') + }) + + it('templates --archived goes out as showArchived=true', async () => { + await bodyOf(['templates', '--archived']) + expect(calls[0]?.query.get('showArchived')).toBe('true') + }) + + it('preview sends the hash as a query field', async () => { + await bodyOf(['preview', '8f2a91c4']) + expect(calls[0]?.query.get('hash')).toBe('8f2a91c4') + }) +}) diff --git a/tests/sms.test.ts b/tests/sms.test.ts new file mode 100644 index 0000000..709db2e --- /dev/null +++ b/tests/sms.test.ts @@ -0,0 +1,270 @@ +import {runCommand as runOclif} from '@oclif/test' +import {Config} from '@oclif/core' +import {fileURLToPath} from 'node:url' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' + +import SmsCampaignBatch from '../src/commands/sms/campaign/batch.js' +import SmsCampaignBatches from '../src/commands/sms/campaign/batches.js' +import SmsCampaignDelete from '../src/commands/sms/campaign/delete.js' +import SmsCampaignMessage from '../src/commands/sms/campaign/message.js' +import SmsCampaignMessages from '../src/commands/sms/campaign/messages.js' +import SmsDisable from '../src/commands/sms/disable.js' +import SmsDisableDependencies from '../src/commands/sms/disable-dependencies.js' +import SmsEnable from '../src/commands/sms/enable.js' +import SmsIntegration from '../src/commands/sms/integration.js' +import SmsIntegrationConfirmDelivery from '../src/commands/sms/integration/confirm-delivery.js' +import SmsIntegrationDefault from '../src/commands/sms/integration/default.js' +import SmsIntegrationDelete from '../src/commands/sms/integration/delete.js' +import SmsIntegrationDisable from '../src/commands/sms/integration/disable.js' +import SmsIntegrationEnable from '../src/commands/sms/integration/enable.js' +import SmsIntegrationTest from '../src/commands/sms/integration/test.js' +import SmsIntegrations from '../src/commands/sms/integrations.js' +import SmsPreview from '../src/commands/sms/preview.js' +import SmsSettings from '../src/commands/sms/settings.js' +import SmsTemplateRender from '../src/commands/sms/template/render.js' +import SmsTemplateTokens from '../src/commands/sms/template/tokens.js' +import {runCommand} from './_helpers.js' + +const ID = '66b2f0a1' +const BATCH = 'b7c3' +const MESSAGE = 'n9d4' + +describe('sms module', () => { + it('enable turns the module on', async () => { + const {calls, output} = await runCommand(SmsEnable, []) + expect(calls).toEqual([{method: 'enableSms', request: {}}]) + expect(output).toEqual(['SMS enabled.']) + }) + + it('disable --yes turns the module off', async () => { + const {calls, output} = await runCommand(SmsDisable, ['--yes']) + expect(calls).toEqual([{method: 'disableSms', request: {}}]) + expect(output).toEqual(['SMS disabled.']) + }) + + it('disable-dependencies lists what still depends on SMS', async () => { + const {calls} = await runCommand(SmsDisableDependencies, []) + expect(calls).toEqual([{method: 'getSmsDisableDependencies', request: {}}]) + }) +}) + +describe('sms settings', () => { + it('reads the project settings', async () => { + const {calls} = await runCommand(SmsSettings, []) + expect(calls).toEqual([{method: 'getSmsSettings', request: {}}]) + }) + + it('passes an explicit settings id', async () => { + const {calls} = await runCommand(SmsSettings, ['--id', ID]) + expect(calls[0]?.request).toEqual({id: ID}) + }) +}) + +describe('sms preview', () => { + it('sends the preview hash', async () => { + const {calls} = await runCommand(SmsPreview, ['8f2a91c4']) + expect(calls).toEqual([{method: 'previewSmsNotification', request: {hash: '8f2a91c4'}}]) + }) + + it('takes the hash from --hash too', async () => { + const {calls} = await runCommand(SmsPreview, ['--hash', 'abc.def']) + expect(calls).toEqual([{method: 'previewSmsNotification', request: {hash: 'abc.def'}}]) + }) + + it('refuses to run with no hash at all', async () => { + await expect(runCommand(SmsPreview, [])).rejects.toThrow(/preview hash/) + }) +}) + +/** + * The signed preview link opens without sign-in: the hash is the key. These run + * the real command through oclif and the real `@norbix.ai/ts` transport, with + * `fetch` replaced — nothing leaves the process. The test HOME is empty (see + * test/setup.ts), so there is no login session and no profile. + */ +describe('sms preview — signed link, no login, no project', () => { + interface Sent { + url: URL + headers: Headers + } + + let sent: Sent[] + let realFetch: typeof globalThis.fetch + + beforeEach(() => { + sent = [] + realFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + sent.push({url: new URL(href), headers: new Headers(init?.headers)}) + return new Response(JSON.stringify({body: 'Hi Ada, your code is 4242'}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = realFetch + }) + + it('does not ask for a project, and sends no project header', async () => { + const {error} = await runOclif(['sms', 'preview', '--hash', 'abc.def', '--api-key', 'k-1', '--region', 'nb-eu-germany']) + + expect(error).toBeUndefined() + expect(sent).toHaveLength(1) + expect(sent[0]!.url.pathname).toBe('/v2/notifications/sms/preview') + expect(sent[0]!.url.searchParams.get('hash')).toBe('abc.def') + expect(sent[0]!.headers.get('norbix-project-id')).toBeNull() + expect(sent[0]!.headers.get('X-CM-ProjectId')).toBeNull() + }) + + it('still uses a login when there is one', async () => { + const {error} = await runOclif([ + 'sms', 'preview', 'abc.def', '--api-key', 'k-1', '--project', 'p-1', '--region', 'nb-eu-germany', + ]) + + expect(error).toBeUndefined() + expect(sent[0]!.headers.get('Authorization')).toBe('Bearer k-1') + expect(sent[0]!.headers.get('norbix-project-id')).toBe('p-1') + }) + + it('sends the request with no Authorization header when there is no login', async () => { + // @norbix.ai/ts 4.2.0 scopes the preview method 'optional': the SDK does + // not refuse, and nothing is signed — the link's hash is the key. + const {error} = await runOclif(['sms', 'preview', '--hash', 'abc.def', '--region', 'nb-eu-germany']) + + expect(error).toBeUndefined() + expect(sent).toHaveLength(1) + expect(sent[0]!.headers.get('Authorization')).toBeNull() + }) +}) + +describe('sms integrations', () => { + it('lists with no paging by default', async () => { + const {calls} = await runCommand(SmsIntegrations, []) + expect(calls[0]?.method).toBe('getSmsIntegrations') + expect(calls[0]?.request).toEqual({pageSize: undefined, startingAfter: undefined}) + }) + + it('passes page size and cursor', async () => { + const {calls} = await runCommand(SmsIntegrations, ['--page-size', '50', '--after', 'cur_1']) + expect(calls[0]?.request).toEqual({pageSize: 50, startingAfter: 'cur_1'}) + }) + + it('shows one integration', async () => { + const {calls} = await runCommand(SmsIntegration, [ID]) + expect(calls).toEqual([{method: 'getSmsIntegration', request: {id: ID}}]) + }) +}) + +describe('sms integration state changes', () => { + const cases = [ + {command: SmsIntegrationEnable, method: 'enableSmsIntegration', argv: [ID], output: `Integration ${ID} enabled.`}, + {command: SmsIntegrationDisable, method: 'disableSmsIntegration', argv: [ID], output: `Integration ${ID} disabled.`}, + {command: SmsIntegrationDefault, method: 'setSmsIntegrationAsDefault', argv: [ID], output: `Integration ${ID} is now the default.`}, + {command: SmsIntegrationDelete, method: 'deleteSmsIntegration', argv: [ID, '--yes'], output: `Integration ${ID} deleted.`}, + ] + + for (const {command, method, argv, output} of cases) { + it(`${method} sends the integration id and says what it did`, async () => { + const result = await runCommand(command, argv) + expect(result.calls).toEqual([{method, request: {id: ID}}]) + expect(result.output).toEqual([output]) + }) + } +}) + +describe('sms integration test', () => { + it('sends a test SMS through the chosen integration to a phone number', async () => { + const {calls} = await runCommand(SmsIntegrationTest, ['--integration', ID, '--to', '+37060000000']) + expect(calls).toEqual([{method: 'testSmsIntegration', request: {integrationId: ID, to: '+37060000000'}}]) + }) + + it('lets the server pick the number when --to is left out', async () => { + const {calls} = await runCommand(SmsIntegrationTest, ['--integration', ID]) + expect(calls).toEqual([{method: 'testSmsIntegration', request: {integrationId: ID, to: undefined}}]) + }) + + it('requires an integration id', async () => { + await expect(runCommand(SmsIntegrationTest, ['--to', '+37060000000'])).rejects.toThrow() + }) +}) + +describe('sms integration confirm-delivery', () => { + it('confirms a human saw the test SMS', async () => { + const {calls, output} = await runCommand(SmsIntegrationConfirmDelivery, [ID]) + expect(calls).toEqual([{method: 'confirmSmsIntegrationHumanDelivery', request: {integrationId: ID}}]) + expect(output).toEqual([`Delivery confirmed for integration ${ID}.`]) + }) +}) + +describe('sms template tokens and render', () => { + it('lists the tokens of a template', async () => { + const {calls} = await runCommand(SmsTemplateTokens, [ID]) + expect(calls).toEqual([{method: 'getSmsMessageContentTokens', request: {id: ID}}]) + }) + + it('renders Razor text with Custom token values', async () => { + const {calls} = await runCommand(SmsTemplateRender, ['--code', 'Hi @Model.Name', '--token', 'Name=Ada', '--preview']) + expect(calls).toEqual([ + { + method: 'renderSms', + request: {code: 'Hi @Model.Name', tokens: [{key: 'Name', value: 'Ada', resolver: 'Custom'}], isForPreview: true}, + }, + ]) + }) + + it('refuses a token that is not key=value', async () => { + await expect(runCommand(SmsTemplateRender, ['--code', 'x', '--token', 'Name'])).rejects.toThrow(/key=value/) + }) +}) + +describe('sms campaign reads and delete', () => { + it('batches pass the campaign id and paging', async () => { + const {calls} = await runCommand(SmsCampaignBatches, [ID, '--page-size', '10', '--after', 'cur_1']) + expect(calls).toEqual([{method: 'getSmsCampaignBatches', request: {id: ID, pageSize: 10, startingAfter: 'cur_1'}}]) + }) + + it('batch lists the notifications of one batch', async () => { + const {calls} = await runCommand(SmsCampaignBatch, [ID, BATCH]) + expect(calls).toEqual([ + {method: 'getSmsCampaignBatchNotifications', request: {id: ID, batchId: BATCH, pageSize: undefined, startingAfter: undefined}}, + ]) + }) + + it('batch with a notification id shows just that notification', async () => { + const {calls} = await runCommand(SmsCampaignBatch, [ID, BATCH, MESSAGE]) + expect(calls).toEqual([{method: 'getSmsCampaignBatchNotification', request: {id: ID, batchId: BATCH, notificationId: MESSAGE}}]) + }) + + it('messages narrows to a batch and pages', async () => { + const {calls} = await runCommand(SmsCampaignMessages, [ID, '--batch', BATCH, '--page-size', '5']) + expect(calls).toEqual([ + {method: 'getSmsCampaignMessages', request: {campaignId: ID, campaignBatchId: BATCH, pageSize: 5, startingAfter: undefined}}, + ]) + }) + + it('message names the fields the way the route does: campaignId + notificationId', async () => { + const {calls} = await runCommand(SmsCampaignMessage, [ID, MESSAGE, '--batch', BATCH]) + expect(calls).toEqual([{method: 'getSmsCampaignMessage', request: {campaignId: ID, notificationId: MESSAGE, campaignBatchId: BATCH}}]) + }) + + it('message requires the batch', async () => { + await expect(runCommand(SmsCampaignMessage, [ID, MESSAGE])).rejects.toThrow() + }) + + it('delete --yes deletes the campaign', async () => { + const {calls, output} = await runCommand(SmsCampaignDelete, [ID, '--yes']) + expect(calls).toEqual([{method: 'deleteSmsCampaign', request: {id: ID}}]) + expect(output).toEqual([`Campaign ${ID} deleted.`]) + }) +}) + +it('every push command has an sms twin, except the device commands (SMS has no devices)', async () => { + const config = await Config.load(fileURLToPath(new URL('..', import.meta.url))) + const push = config.commandIDs.filter((id) => id.startsWith('push:') && !id.startsWith('push:device')) + const missing = push.map((id) => id.replace(/^push:/, 'sms:')).filter((id) => !config.findCommand(id)) + expect(push.length).toBeGreaterThan(20) + expect(missing).toEqual([]) +}) From 581b75d39eaf56fe41d5315d11c31f6dc3e83d85 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 2 Oct 2026 16:51:19 +0300 Subject: [PATCH 4/4] [6] docs(sms): docs/sms.md with one line per command; README and COVERAGE rows say every SMS endpoint is covered Co-Authored-By: Claude Fable 5.1 --- COVERAGE.md | 14 ++--- README.md | 2 +- docs/sms.md | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 docs/sms.md diff --git a/COVERAGE.md b/COVERAGE.md index 14dec51..5757317 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -29,7 +29,7 @@ out of reach. | membership/roles + policies | 2/10 | read-only lists | | notifications/email | 12/47 | templates (list/get/clone/archive/unarchive/delete), campaigns (list/get/stats/stop), module toggle, integrations list | | notifications/push | 37/37 | every endpoint — see `docs/push.md`; each command is run through the real transport in `tests/push-routes.test.ts` | -| notifications/sms | 12/35 | same set as email | +| notifications/sms | 35/35 | every endpoint — see `docs/sms.md`; each command is run through the real transport in `tests/sms-routes.test.ts` | | scheduler | 7/8 | all except save-task (complex DTO) | | webhooks | 6/9 | show, secret, rotate, enable/disable/remove destination | | payments | 7/16 | integrations list, triggers list/get/enable/disable, module toggle | @@ -54,16 +54,16 @@ Cross-cutting commands: `norbix integrations ` lists integrations for `norbix api` if ever needed. - **code/marketplace (17)** — new surface; candidate for a future `norbix fx` topic (list/invoke marketplace function bindings could be very useful). -- **email/sms create/update template + create campaign** — bodies are big +- **email create/update template + create campaign** — bodies are big design objects (MJML, layouts); better done in the portal today. `norbix api` - works for scripted cases. (Push has these commands: its template is title + - body per language, which fits flags.) + works for scripted cases. (Push and SMS have these commands: their template + is a short text per language, which fits flags.) ## Version note -`email|sms stop` need SDK methods newer than `@norbix.ai/ts@1.2.0`. The -commands detect an older SDK and print the exact `norbix api` fallback line. -`push stop` calls the SDK directly (the CLI requires `@norbix.ai/ts` ^1.3.0). +The CLI requires `@norbix.ai/ts` ^4.2.0, which has every push and SMS method; +`push stop` and `sms stop` call the SDK directly. `email stop` still carries +the older-SDK guard that prints a `norbix api` fallback line. ## How this was measured diff --git a/README.md b/README.md index f7f5b4c..0697b06 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ norbix scheduler list | `norbix webhooks show/secret/enable/disable/remove` | Inspect the webhook integration | | `norbix email templates/template/clone/archive/unarchive/delete/campaigns/campaign/stop` | Email templates and campaigns | | `norbix push ...` | Push module, integrations, devices, templates, campaigns — every push endpoint, see [docs/push.md](docs/push.md) | -| `norbix sms ...` | SMS — same commands as email | +| `norbix sms ...` | SMS module, integrations, templates, campaigns — every SMS endpoint, see [docs/sms.md](docs/sms.md) | | `norbix account profile/status/usage/projects/team/regions/billing-portal` | Account-level info | | `norbix payments integrations/triggers/trigger/enable/disable` | Payment integrations and triggers | | `norbix integrations ` | List integrations of any module | diff --git a/docs/sms.md b/docs/sms.md new file mode 100644 index 0000000..1a9f8c2 --- /dev/null +++ b/docs/sms.md @@ -0,0 +1,141 @@ +# `norbix sms` + +Manage SMS from the terminal. Every SMS endpoint has a command. + +Every command is covered by `tests/sms-routes.test.ts`, which runs it through +the real SDK with `fetch` replaced and checks the verb and path it sends (so a +wrong route token fails a test). `tests/sms.test.ts` and +`tests/sms-existing.test.ts` also check the request fields. No test contacts an +SMS provider; the only provider a test saves is **Fake**. + +All paths below are under `/{version}/notifications/sms`. + +## Module + +| command | what it does | endpoint | +|---|---|---| +| `norbix sms enable` | turn the SMS module on | `GET /enable` | +| `norbix sms disable [--yes]` | turn the SMS module off | `GET /disable` | +| `norbix sms disable-dependencies` | list what still depends on SMS — check before `disable` | `GET /disable-dependencies` | +| `norbix sms settings [--id ]` | show the project SMS settings | `GET /settings` | + +`norbix module enable sms` / `norbix module disable sms` do the same as the +first two, for scripts that treat every module alike. + +## Integrations + +| command | what it does | endpoint | +|---|---|---| +| `norbix sms integrations [--page-size ] [--after ]` | list the integrations | `GET /integrations` | +| `norbix sms integration ` | show one integration | `GET /integrations/{id}` | +| `norbix sms integration save --provider

[--name ] [--id ] [--disabled] [--config ]` | create or update an integration | `POST /integrations` | +| `norbix sms integration enable ` | turn it on | `PUT /integrations/{Id}/enable` | +| `norbix sms integration disable ` | turn it off | `PUT /integrations/{Id}/disable` | +| `norbix sms integration default ` | make it the project default | `PUT /integrations/{Id}/default` | +| `norbix sms integration delete [--yes]` | delete it | `DELETE /integrations/{Id}` | +| `norbix sms integration test --integration [--to ]` | send a test SMS | `POST /integrations/test` | +| `norbix sms integration confirm-delivery ` | confirm the test SMS reached a person | `POST /integrations/confirm-human-delivery` | + +`--provider` is one of `Fake`, `Twilio`, `Vonage`, `Plivo`, `Telnyx`, `Bird`, +`Telesign`, `Sinch`. **Fake** needs nothing else: it accepts every send and +contacts no SMS service, so nothing reaches a real phone — use it while you +develop. A project can hold one Fake and it has no update path: delete it and +add it again. The real providers need their credentials: put the +provider-specific fields in `--config` as a JSON object (inline, `@file.json`, +or `-` for stdin). + +```bash +norbix sms integration save --provider Fake +norbix sms integration save --provider Twilio --name Twilio --config @twilio.json +norbix sms integration test --integration int_123 --to +37060000000 +norbix sms integration confirm-delivery int_123 +``` + +## Templates + +| command | what it does | endpoint | +|---|---|---| +| `norbix sms templates [--archived]` | list templates | `GET /templates` | +| `norbix sms template ` | show one template | `GET /templates/{id}` | +| `norbix sms template create --name (--body [--subject ] \| --translations )` | create a template | `POST /templates` | +| `norbix sms template update --name (--body [--subject ] \| --translations )` | replace a template | `PUT /templates` | +| `norbix sms template tokens ` | list the tokens the template uses | `GET /templates/{id}/tokens` | +| `norbix sms template render --code [--token key=value]... [--preview]` | render text with token values | `POST /templates/render` | +| `norbix sms archive ` | archive a template | `PUT /templates/{Id}/archive` | +| `norbix sms unarchive ` | restore an archived template | `PUT /templates/{Id}/unarchive` | +| `norbix sms clone ` | copy a template | `POST /templates/{Id}/clone` | +| `norbix sms delete [--yes]` | delete a template | `DELETE /templates/{Id}` | + +`create` and `update` also take `--description`, `--channel` +(`Transactional` · `Marketing` · `System`, default `Transactional`) and `--tag` +(repeat for several). For one language use `--body` (plus `--language`, +default `en`, and `--subject` — the sender id, empty by default, as in the +portal). For several, pass `--translations` with a JSON array of +`{"language": "en", "content": {"subject": "", "body": "…"}}` (inline, +`@file.json`, or `-`). `update` sends the whole template, so include every +language you want to keep. Tokens are Razor: `@Model.Name`, not `{{Name}}`. + +```bash +norbix sms template create --name Welcome --body "Hi @Model.Name, your code is @Model.Code" +norbix sms template tokens tpl_123 +norbix sms template render --code "Hi @Model.Name" --token Name=Ada +``` + +## Campaigns + +| command | what it does | endpoint | +|---|---|---| +| `norbix sms campaigns [--page-size ] [--after ]` | list campaigns | `GET /campaigns` | +| `norbix sms campaign ` | show a campaign | `GET /campaigns/{id}` | +| `norbix sms campaign --stats` | show its delivery statistics instead | `GET /campaigns/{id}/stats` | +| `norbix sms campaign create --template --audience …` | create (and send or schedule) a campaign | `POST /campaigns` | +| `norbix sms campaign delete [--yes]` | delete a campaign | `DELETE /campaigns/{id}` | +| `norbix sms stop [--yes]` | stop a running campaign | `POST /campaigns/{Id}/stop` | +| `norbix sms campaign batches ` | list its send batches | `GET /campaigns/{id}/batches` | +| `norbix sms campaign batch ` | list the notifications in a batch | `GET /campaigns/{id}/batches/{batchId}` | +| `norbix sms campaign batch ` | show one notification in a batch | `GET /campaigns/{id}/batches/{batchId}/{notificationId}` | +| `norbix sms campaign messages [--batch ]` | list the messages it sent | `GET /campaigns/{campaignId}/messages` | +| `norbix sms campaign message --batch ` | show one message | `GET /campaigns/{campaignId}/messages/{notificationId}` | +| `norbix sms preview ` (or `--hash `) | render the text behind a preview link | `GET /preview` | + +`--audience` decides who receives the campaign: + +| `--audience` | recipients | flags | +|---|---|---| +| `all-users` | every user with a phone number | narrow with `--role`, `--tag` | +| `users` | the named users | `--user` (repeat) | +| `collection` | records of a collection whose field holds the recipient | `--schema`, `--field` (repeat), `--field-type User\|Email`, `--role` | +| `phone-numbers` | raw phone numbers in international format | `--phone` (repeat) | + +Other `create` flags: `--language`, `--at` (send later — ISO 8601 date-time +or Unix seconds; without it the campaign is sent right away), +`--respect-time-zone last-login|registration|registration-project` (send +`--at` in each recipient's own time zone), `--token key=value` (repeat), +`--database-integration`, and `--config` for any other delivery-settings +field as a JSON object. The integration is the project default one. + +```bash +norbix sms campaign create --template tpl_123 --audience all-users --tag beta +norbix sms campaign create --template tpl_123 --audience phone-numbers --phone +37060000000 --at 2026-10-01T09:00:00Z +norbix sms campaign batches cmp_123 +``` + +`sms preview` takes the opaque hash the backend puts in a preview link — you +cannot build it yourself, so copy it out of the link. + +The signed link is the key: `sms preview` needs **no login and no project** +(the default endpoints still need `--region`). When you are logged in, your +session is still sent. A `401` means the link is invalid or expired. + +```sh +norbix sms preview --hash 8f2a91c4... --region nb-eu-germany +``` + +## Not offered on purpose + +- **`--audience account-users`**: the gateway's audience enum lists + `AccountUsers`, but the Sms campaign request has no settings block for it, + so the server would refuse the campaign. Push has it; SMS does not yet. +- **Choosing the integration per campaign**: the Sms campaign request has no + integration field — the project default integration sends every campaign + (`sms integration default ` picks it). diff --git a/package.json b/package.json index 876524c..584f267 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "description": "Manage scheduler tasks" }, "sms": { - "description": "SMS templates and campaigns" + "description": "SMS module, integrations, templates and campaigns" }, "users": { "description": "Manage project users (membership)"