Let sendSms() name the esm_class messaging mode (#92)
* Regression tests for the messaging mode on sendSms() * Let sendSms() name the esm_class messaging mode * Document the messaging mode option and the decision behind it * Name the constants move for migrating consumers and share the refusal's value naming * Cover the messaging-mode refusals through submitSms() * Refuse transaction mode and a report under datagram mode on sendSms() * Narrow the mode checker and name the default mode once
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+26
-3
@@ -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<MessagingMode, typeof transactionMode>;
|
||||
|
||||
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<string, Record<number, string>> = {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+54
-5
@@ -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<SendSmsOptions, 'messagingMode'> & { 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<string, ParamValue> {
|
||||
@@ -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<SendSmsResult> {
|
||||
export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<SendSmsResult> {
|
||||
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);
|
||||
|
||||
@@ -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}`) };
|
||||
}
|
||||
|
||||
@@ -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<BoundPeer> {
|
||||
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<void>(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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user