diff --git a/AGENTS.md b/AGENTS.md index 3870ed9..3112eef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ src/ concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs dlr.ts Delivery receipts: text and TLV parsing, receipt status codes dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr - error-from.ts errorFrom(): whatever was thrown or rejected, as an Error + error-from.ts An untyped value as error material: errorFrom() an Error, namedValue() a name expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share held-messages.ts HeldMessages: capped, expiring messages the application has not answered idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget @@ -359,6 +359,29 @@ Grouped by what each one constrains. goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second spelling of one message whose only difference is which peers accept it. +- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller + writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer + the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and + Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every + segment through `send()` — giving up the split, the per-segment ids, the send window and the + receipt merge, which is what goal 6 means by beating "the application can do this itself". The four + modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their + names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH + indicator a segment carrying a header needs cannot be cleared by anything the option can express. + It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out + of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by + that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 6 + will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the + report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a + report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they + had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number, + which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected: + taking a number beside a name, two spellings of one goal — which is why a value naming no mode is + refused, by name, before a segment goes out. Rejected: a session-level default with a per-send + override; an operator's requirement is a property of the link, but the library can verify nothing + the caller's own options object does not, and shipping both buys a precedence rule to document and + test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable. + - **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and diff --git a/README.md b/README.md index 942907d..bd7a167 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ await session.sendSms({ from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1 maxSegments: 10, // refuse a longer message instead of sending it message: 'Hello world', + messagingMode: 'SMSC_DEFAULT', // or DATAGRAM or STORE_FORWARD scheduleDeliveryTime: new Date(Date.now() + 3600_000), sourceAddrNpi: 0, // override the numbering plan of the sender sourceAddrTon: 5, @@ -112,6 +113,15 @@ await session.sendSms({ `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address and 1 for a numeric one; the NPI fields default to 0. Set them for an operator that requires something else. +`messagingMode` names the `esm_class` messaging mode: `SMSC_DEFAULT`, which is what an omitted option +sends, or `DATAGRAM` or `STORE_FORWARD`. Every segment of a long message carries the user data header +indicator beside it, so an operator that requires `esm_class` 0x43 on a concatenated message gets +exactly that from `STORE_FORWARD`. SMPP carries transaction mode on `data_sm`, which this never +sends, so reach for one of the three above rather than `consts.MESSAGING_MODE.FORWARD`. Datagram mode +defines the delivery report away, so pair it with `dlr: true` and the send is refused rather than +leaving you waiting for a report that cannot come — as is any value naming no mode, both before a +segment goes out. + Messages too long for one SMS are split automatically and sent as a concatenated message. You get one id per segment: @@ -546,6 +556,11 @@ promises and the rough edges taken off. for a status code the library does not know, with the raw number in `pduObj.cmdStatusId`. - **`defs.filters` is gone.** It was declared on every command and TLV but never invoked, so it did nothing. SMPP time formatting, the one part worth keeping, is exported as `smppTime`. +- **`DATAGRAM`, `FORWARD` and `STORE_FORWARD` moved from `consts.ESM_CLASS` to + `consts.MESSAGING_MODE`**, which also names the fourth mode, `SMSC_DEFAULT`. They are bits 1-0 of + `esm_class` rather than whole values of it. Read them from the new group, or let `sendSms()` write + one for you as `messagingMode`; a stale `consts.ESM_CLASS.STORE_FORWARD` now reads `undefined`, + which OR-s into an `esm_class` that silently carries no mode at all. - **The `error` event is `sessionError`** (and `serverError` on the server handle). - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a `larvitutils` one, and is silent by default. See [Logging](#logging). diff --git a/src/defs/constants.ts b/src/defs/constants.ts index 0104e7e..ff89833 100644 --- a/src/defs/constants.ts +++ b/src/defs/constants.ts @@ -43,13 +43,10 @@ export const consts = { }, ESM_CLASS: { CONVERSATION_ABORT: 0x18, - DATAGRAM: 0x01, DELIVERY_ACKNOWLEDGEMENT: 0x08, - FORWARD: 0x02, INTERMEDIATE_DELIVERY: 0x20, MC_DELIVERY_RECEIPT: 0x04, SET_REPLY_PATH: 0x80, - STORE_FORWARD: 0x03, UDH_INDICATOR: 0x40, USER_ACKNOWLEDGEMENT: 0x10, }, @@ -65,6 +62,13 @@ export const consts = { UNDELIVERABLE: 5, UNKNOWN: 7, }, + /** SMPP 3.4 5.2.12 bits 1-0 of esm_class, the field the rest of that octet is OR-ed into. */ + MESSAGING_MODE: { + DATAGRAM: 0x01, + FORWARD: 0x02, + SMSC_DEFAULT: 0x00, + STORE_FORWARD: 0x03, + }, NETWORK: { CDMA: 0x03, GENERIC: 0x00, @@ -114,6 +118,25 @@ export function messageTypeOf(esmClass: number): number { export type ConstGroup = keyof typeof consts; export type MessageState = keyof typeof consts.MESSAGE_STATE; +export type MessagingMode = keyof typeof consts.MESSAGING_MODE; + +/** SMPP 3.4 2.10.3 carries transaction mode on `data_sm` alone, so a `submit_sm` never asks for it. */ +const transactionMode = 'FORWARD' satisfies MessagingMode; + +export const defaultMessagingMode = 'SMSC_DEFAULT' satisfies MessagingMode; + +export type SubmitMessagingMode = Exclude; + +export const submitMessagingModes: readonly string[] = Object.keys(consts.MESSAGING_MODE) + .filter(mode => mode !== transactionMode); + +export function isMessagingMode(value: unknown): value is MessagingMode { + return typeof value === 'string' && Object.hasOwn(consts.MESSAGING_MODE, value); +} + +export function isSubmitMessagingMode(value: unknown): value is SubmitMessagingMode { + return isMessagingMode(value) && value !== transactionMode; +} // Aliased values (NPI.IP === NPI.INTERNET === 0x0E) resolve to whichever name sorts last. export const constsById: Record> = {}; diff --git a/src/error-from.ts b/src/error-from.ts index a091479..b2ccbd4 100644 --- a/src/error-from.ts +++ b/src/error-from.ts @@ -8,3 +8,8 @@ export function errorFrom(reason: unknown): Error { return new Error('A thrown value that cannot be converted to a string'); } } + +/** String() throws on a null-prototype object or a symbol, so only a string or number is printed. */ +export function namedValue(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' ? String(value) : typeof value; +} diff --git a/src/index.ts b/src/index.ts index e88dec0..ef41ec4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,7 +61,7 @@ export type { SessionOptions, } from './session.ts'; export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; -export type { ConstGroup, MessageState } from './defs/constants.ts'; +export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts'; export type { Encoding, EncodingName } from './defs/encodings.ts'; export type { ErrorName } from './defs/errors.ts'; export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; diff --git a/src/send-sms.ts b/src/send-sms.ts index 8b018b4..8729e2d 100644 --- a/src/send-sms.ts +++ b/src/send-sms.ts @@ -1,12 +1,14 @@ import type { EncodingName } from './defs/encodings.ts'; import type { ParamValue } from './defs/types.ts'; +import type { SubmitMessagingMode } from './defs/constants.ts'; import type { PduObject, PduObjectInput } from './pdu.ts'; import type { Result } from './result.ts'; import type { SmppLog } from './log.ts'; import type { SmsIdNotation } from './sms-id.ts'; import { UnansweredError } from './unanswered-error.ts'; -import { consts } from './defs/constants.ts'; +import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; import { detect } from './defs/encodings.ts'; +import { namedValue } from './error-from.ts'; import { normaliseSmsId } from './sms-id.ts'; import { paramText } from './defs/types.ts'; import { maxSegments, smppTime, splitMessage } from './message.ts'; @@ -21,6 +23,8 @@ export type SendSmsOptions = { /** Refuse before sending anything if the message needs more than this many segments. */ maxSegments?: number; message: string; + /** The esm_class messaging mode, SMPP 3.4 5.2.12. Absent leaves the choice to the SMSC. */ + messagingMode?: SubmitMessagingMode; scheduleDeliveryTime?: Date | number | string; sourceAddrNpi?: number; sourceAddrTon?: number; @@ -28,6 +32,9 @@ export type SendSmsOptions = { validityPeriod?: Date | number | string; }; +/** The options as they arrive: a caller without types can put anything in the checked field. */ +export type SendSmsInput = Omit & { messagingMode?: unknown }; + /** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */ export type SendSmsResult = { err?: Error; @@ -52,6 +59,7 @@ export type SendSmsDeps = { type SegmentOptions = { encoding: EncodingName; + messagingMode?: SubmitMessagingMode; multipart: boolean; }; @@ -67,8 +75,15 @@ function dataCodingFor(encoding: EncodingName, flash: boolean): number { return encoding === 'UCS2' ? 0x18 : 0x10; } +/** The mode the caller named sits beside the UDH indicator, which a segment carrying one must keep. */ +function esmClassFor(mode: SubmitMessagingMode | undefined, multipart: boolean): number { + const udh = multipart ? consts.ESM_CLASS.UDH_INDICATOR : 0; + + return consts.MESSAGING_MODE[mode ?? defaultMessagingMode] | udh; +} + export function submitSmParams( - sms: SendSmsOptions, + sms: SendSmsInput, segment: Buffer, options: SegmentOptions, ): Record { @@ -77,13 +92,13 @@ export function submitSmParams( destination_addr: sms.to, dest_addr_npi: sms.destinationAddrNpi ?? 0, dest_addr_ton: sms.destinationAddrTon ?? addressTon(sms.to), + esm_class: esmClassFor(options.messagingMode, options.multipart), short_message: segment, source_addr: sms.from, source_addr_npi: sms.sourceAddrNpi ?? 0, source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from), }; - if (options.multipart) params.esm_class = consts.ESM_CLASS.UDH_INDICATOR; if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL; if (sms.scheduleDeliveryTime !== undefined) { params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime); @@ -95,6 +110,36 @@ export function submitSmParams( return params; } +function refusedMode(mode: unknown): Error { + if (isMessagingMode(mode)) { + return new Error(`messagingMode ${mode} is data_sm only (SMPP 3.4 2.10.3), name ${submitMessagingModes.join(', ')}`); + } + + return new Error(`messagingMode must be ${submitMessagingModes.join(', ')}, got ${namedValue(mode)}`); +} + +/** SMPP 3.4 2.10.2 defines the delivery report away under datagram mode, so one asked for never comes. */ +function checkedMode( + messagingMode: SubmitMessagingMode, + dlr: boolean, +): Result<{ messagingMode: SubmitMessagingMode }> { + if (dlr && messagingMode === 'DATAGRAM') { + return { err: new Error('messagingMode DATAGRAM has no delivery report to ask for, so dlr must be false') }; + } + + return { messagingMode }; +} + +function checkMessagingMode( + mode: unknown, + dlr: boolean, +): Result<{ messagingMode: SubmitMessagingMode }> { + if (mode === undefined) return checkedMode(defaultMessagingMode, dlr); + if (isSubmitMessagingMode(mode)) return checkedMode(mode, dlr); + + return { err: refusedMode(mode) }; +} + /** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */ function checkSegments(allowed: number, segments: number): Error | undefined { if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) { @@ -140,7 +185,11 @@ function collectSent( } /** Puts a message on the wire as one submit_sm per segment. */ -export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise { +export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise { + const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true); + + if (mode.err) return unsent(mode.err); + const allowed = sms.maxSegments ?? maxSegments; const encoding = sms.encoding ?? detect(sms.message); const segments = splitMessage(sms.message, { encoding, reference: deps.reference }); @@ -156,7 +205,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise // segment before answering — this library's own server does — would otherwise deadlock. const sent = await Promise.all(segments.map(segment => deps.send({ cmdName: 'submit_sm', - params: submitSmParams(sms, segment, { encoding, multipart }), + params: submitSmParams(sms, segment, { encoding, messagingMode: mode.messagingMode, multipart }), }))); return collectSent(sent, deps.respIdNotation); diff --git a/src/session-options.ts b/src/session-options.ts index c6d5ea7..c085a22 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -10,6 +10,7 @@ import type { Sms } from './sms.ts'; import type { Socket } from 'node:net'; import { backoffDefaults } from './reconnect-loop.ts'; import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; +import { namedValue } from './error-from.ts'; export type SessionEvents = { close: []; @@ -227,8 +228,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult { if (notation === undefined || isSmsIdNotation(notation)) continue; - // String() throws on a null-prototype object, and this value is whatever the caller passed. - const got = typeof notation === 'string' ? notation : typeof notation; + const got = namedValue(notation); return { err: new Error(`smsIdFormat.${place} must be ${smsIdNotations.join(' or ')}, got ${got}`) }; } diff --git a/test/messaging-mode.test.ts b/test/messaging-mode.test.ts new file mode 100644 index 0000000..5a85c19 --- /dev/null +++ b/test/messaging-mode.test.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import net from 'node:net'; +import test, { describe } from 'node:test'; +import type { PduObjectInput } from '../src/pdu.ts'; +import type { SendSmsDeps } from '../src/send-sms.ts'; +import type { Session } from '../src/session.ts'; +import type { SubmitMessagingMode } from '../src/defs/constants.ts'; +import type { TestContext } from 'node:test'; +import { PduFramer } from '../src/pdu-framer.ts'; +import { client } from '../src/client.ts'; +import { closeAfter, closeListenerAfter } from './teardown.ts'; +import { consts, submitMessagingModes } from '../src/defs/constants.ts'; +import { paramNumber } from '../src/defs/types.ts'; +import { pduReturn, pduToObj } from '../src/pdu.ts'; +import { silentLog } from '../src/log.ts'; +import { submitSms } from '../src/send-sms.ts'; +import { uuidv7 } from '../src/uuid.ts'; + +const from = '46701113311'; +const to = '46709771337'; + +/** Seven copies fill three GSM segments, the last of them short. */ +const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7); + +/** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */ +const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000001000b48656c6c6f20776f726c64'; + +/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */ +const threeSegmentOctets = [ + '000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000001009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564', + '000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000001009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66', + '000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000100710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20', +]; + +type BoundPeer = { + /** Every submit_sm the ESME wrote, exactly as it arrived on the socket. */ + octets: Buffer[]; + session: Session; +}; + +/** An SMSC that answers a bind and every submit, keeping the octets each submit arrived as. */ +async function boundToPeer(t: TestContext): Promise { + const octets: Buffer[] = []; + const sockets: net.Socket[] = []; + const listener = net.createServer(sock => { + const framer = new PduFramer(); + + sockets.push(sock); + sock.on('data', chunk => { + framer.push(chunk); + + for (const pdu of framer.next().pdus ?? []) { + const { pduObj } = pduToObj(pdu); + + if (!pduObj) continue; + + if (pduObj.cmdName === 'submit_sm') octets.push(pdu); + + const answered = pduReturn(pduObj, 'ESME_ROK', pduObj.cmdName === 'submit_sm' + ? { message_id: uuidv7() } + : { system_id: 'byte-peer' }); + + if (answered.buffer) sock.write(answered.buffer); + } + }); + }); + + closeListenerAfter(t, listener, sockets); + await new Promise(resolve => { listener.listen(0, resolve); }); + + const address = listener.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + const { err, session } = await client({ port, reconnect: false }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + + return { octets, session }; +} + +function hexOf(octets: Buffer[]): string[] { + return octets.map(pdu => pdu.toString('hex')); +} + +/** A send that records what it is handed, so a refusal shows up as an empty attempt log. */ +function recordingDeps(attempts: PduObjectInput[]): SendSmsDeps { + return { + log: silentLog, + reference: 1, + send: input => { + attempts.push(input); + + return Promise.resolve({ err: new Error('the recording peer never answers') }); + }, + }; +} + +function esmClassesOf(octets: Buffer[]): number[] { + return octets.map(pdu => { + const { pduObj } = pduToObj(pdu); + + assert.ok(pduObj); + + return paramNumber(pduObj.params.esm_class, 0); + }); +} + +describe('sendSms() with no messagingMode', () => { + test('writes a single-segment message as the octets it has always written', async t => { + const peer = await boundToPeer(t); + const sent = await peer.session.sendSms({ from, message: 'Hello world', to }); + + assert.equal(sent.err, undefined); + assert.deepEqual(hexOf(peer.octets), [singleSegmentOctets]); + }); + + test('writes every segment of a three-segment message as the octets it has always written', async t => { + const peer = await boundToPeer(t); + const sent = await peer.session.sendSms({ from, message: longMessage, to }); + + assert.equal(sent.err, undefined); + assert.equal(sent.smsIds.length, 3); + assert.deepEqual(hexOf(peer.octets), threeSegmentOctets); + }); + + test('writes what SMSC_DEFAULT writes, octet for octet, single-segment and multipart alike', async t => { + const one = await boundToPeer(t); + const single = await one.session.sendSms({ from, message: 'Hello world', messagingMode: 'SMSC_DEFAULT', to }); + + assert.equal(single.err, undefined); + assert.deepEqual(hexOf(one.octets), [singleSegmentOctets]); + + const many = await boundToPeer(t); + const long = await many.session.sendSms({ from, message: longMessage, messagingMode: 'SMSC_DEFAULT', to }); + + assert.equal(long.err, undefined); + assert.deepEqual(hexOf(many.octets), threeSegmentOctets); + }); +}); + +describe('sendSms() messagingMode', () => { + test('gives each mode its own bits — store and forward alone is 0x03, and 0x43 per segment', async t => { + const modes: SubmitMessagingMode[] = ['DATAGRAM', 'SMSC_DEFAULT', 'STORE_FORWARD']; + + assert.deepEqual(modes, submitMessagingModes, 'every mode submit_sm carries is covered here'); + assert.equal(consts.MESSAGING_MODE.FORWARD, 0x02, 'the spec table still names the fourth mode'); + + for (const messagingMode of modes) { + const peer = await boundToPeer(t); + const bits = consts.MESSAGING_MODE[messagingMode]; + const single = await peer.session.sendSms({ from, message: 'Hello world', messagingMode, to }); + const long = await peer.session.sendSms({ from, message: longMessage, messagingMode, to }); + + assert.equal(single.err, undefined); + assert.equal(long.err, undefined); + assert.deepEqual( + esmClassesOf(peer.octets), + [bits, bits | 0x40, bits | 0x40, bits | 0x40], + `${messagingMode} keeps the UDH indicator on every segment carrying one`, + ); + } + }); + + test('refuses a value naming no messaging mode before a segment reaches the wire', async () => { + const attempts: PduObjectInput[] = []; + const deps = recordingDeps(attempts); + const named = /messagingMode must be DATAGRAM, SMSC_DEFAULT, STORE_FORWARD/; + // The bits themselves, the whole esm_class an operator documents, and an esm_class name that + // is no mode at all — the one that would clear the UDH indicator if a number were taken. + const refused = [3, 0x43, 'UDH_INDICATOR', 'store_forward', {}]; + + for (const messagingMode of refused) { + const sent = await submitSms(deps, { from, message: 'Hello world', messagingMode, to }); + + assert.ok(sent.err instanceof Error, JSON.stringify(messagingMode)); + assert.match(sent.err.message, named); + assert.deepEqual(sent.smsIds, []); + assert.equal(sent.unanswered, 0); + } + + assert.match((await submitSms(deps, { from, message: 'x', messagingMode: 3, to })).err?.message ?? '', /got 3/); + assert.match((await submitSms(deps, { from, message: 'x', messagingMode: {}, to })).err?.message ?? '', /got object/); + assert.equal(attempts.length, 0, 'a refused mode puts nothing on the wire'); + }); + + test('refuses transaction mode, which SMPP carries on data_sm and this never sends', async () => { + const attempts: PduObjectInput[] = []; + const sent = await submitSms(recordingDeps(attempts), { + from, + message: 'Hello world', + messagingMode: 'FORWARD', + to, + }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /FORWARD is data_sm only/); + assert.match(sent.err.message, /name DATAGRAM, SMSC_DEFAULT, STORE_FORWARD/); + assert.equal(attempts.length, 0); + }); + + test('refuses a delivery report under datagram mode, which defines one away', async () => { + const attempts: PduObjectInput[] = []; + const deps = recordingDeps(attempts); + const sent = await submitSms(deps, { + dlr: true, + from, + message: 'Hello world', + messagingMode: 'DATAGRAM', + to, + }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /DATAGRAM has no delivery report/); + assert.equal(attempts.length, 0); + + // The mode alone is fine, and so is a report under any mode that has one. + const datagram = await submitSms(deps, { from, message: 'Hello world', messagingMode: 'DATAGRAM', to }); + const reported = await submitSms(deps, { dlr: true, from, message: 'Hello world', to }); + + assert.equal(datagram.err?.message, 'the recording peer never answers'); + assert.equal(reported.err?.message, 'the recording peer never answers'); + assert.equal(attempts.length, 2); + }); +}); diff --git a/test/readme.test.ts b/test/readme.test.ts index ef1593c..f0a127b 100644 --- a/test/readme.test.ts +++ b/test/readme.test.ts @@ -139,6 +139,7 @@ describe('README: Client', () => { flash: false, from: 'MyBrand', message: 'Hello world', + messagingMode: 'SMSC_DEFAULT', scheduleDeliveryTime: new Date(Date.now() + 3600_000), to: '46709771337', validityPeriod: 3600,