Files
smpp-js/interop-tests/jasmin.test.ts
T
lilleman 13e3ff8f1f Say undefined where the SMSC named no id for a segment it took (#101)
* Cover the ids a send gets back where the SMSC named only some of them

* Say undefined where the SMSC named no id for a segment it took

* Record why the merger takes the wider type, and de-duplicate the interop narrowing

* Guard the numbering hole the merger must refuse, and correct the phase record
2026-09-12 22:37:55 +02:00

710 lines
27 KiB
TypeScript

import assert from 'node:assert/strict';
import http from 'node:http';
import test, { after, describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
import type { EncodingName } from '../src/defs/encodings.ts';
import type { PduObject } from '../src/pdu.ts';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import { ConcatReference } from '../src/udh.ts';
import { client } from '../src/client.ts';
import { closeAfter } from '../test/teardown.ts';
import { paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import { encodeMessage, splitMessage } from '../src/message.ts';
import { submitSmParams } from '../src/send-sms.ts';
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
const DATASM_HOST = process.env.DATASM_HOST ?? 'jasmin-datasm';
const HTTP_API_HOST = process.env.HTTP_API_HOST ?? 'jasmin';
const HTTP_API_PORT = Number(process.env.HTTP_API_PORT ?? '1401');
const UPSTREAM_PORT = Number(process.env.UPSTREAM_PORT ?? '2777');
const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080');
const USERNAME = 'esme1';
const PASSWORD = 'esme1pw';
const THROTTLED_USERNAME = 'esme2';
const THROTTLED_PASSWORD = 'esme2pw';
const UPSTREAM_USERNAME = 'upstreamesme';
const UPSTREAM_DATASM_USERNAME = 'upstreamdsesme';
const FROM = '46701113311';
const TO = '46709771337';
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
/** Polls until `get()` stops returning undefined, or the budget runs out. */
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
const deadline = Date.now() + budget;
let value = get();
while (value === undefined && Date.now() < deadline) {
await delay(20);
value = get();
}
return value;
}
async function bind(username: string, password: string, options: Parameters<typeof client>[0] = {}): ReturnType<typeof client> {
return client({ host: PEER_HOST, password, port: PEER_PORT, username, ...options });
}
// --- Shared infra: the fake upstream real-world SMSC that Jasmin's own smppc connector(s) bind
// out to (host `node`, port UPSTREAM_PORT - Jasmin resolves `node` via --use-aliases), and the HTTP
// listener that receives Jasmin's DLR-thrower webhook (S7). Both are wired once for the whole file,
// mirroring kannel.test.ts's shared-infra shape - every Jasmin variant dials in from container
// start, independent of when a given test runs. ---
type UpstreamVariant = 'datasm' | 'main';
const upstreamSessions = new Map<UpstreamVariant, Session>();
const upstreamSms: { sms: Sms; variant: UpstreamVariant }[] = [];
function variantFromSystemId(systemId: string): UpstreamVariant | undefined {
if (systemId === UPSTREAM_USERNAME) return 'main';
if (systemId === UPSTREAM_DATASM_USERNAME) return 'datasm';
return undefined;
}
const { err: upstreamErr, server: upstream } = await server({
authenticate: ({ password, systemId }) => {
// <=8 chars: Jasmin's own bind-PDU encoder enforces SMPP's 8-char password maximum strictly
// (see bootstrap.py) - a longer one made every single connector bind attempt throw.
if (password !== 'upstrmpw') return false;
const variant = variantFromSystemId(systemId);
return variant ? { userData: { variant } } : false;
},
// Jasmin's connector sends enquire_link every elink_interval (30s); this host's own contention
// (rabbitmq/redis under load) sometimes delays it past our server's 40s default idleTimeout,
// dropping the one long-lived connector session. A dropped connection strands whatever submit_sm
// was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past
// any per-test wait budget here - so this is generous specifically to never be the trigger.
idleTimeout: 300_000,
port: UPSTREAM_PORT,
});
assert.equal(upstreamErr, undefined);
assert.ok(upstream);
const upstreamServer = upstream;
upstreamServer.on('session', session => {
// `session` fires on raw connect, before authenticate() has run - session.userData is not set
// yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off
// userData; userData is only read later, from 'sms', where authenticate() has long since run.
session.on('incomingPduObj', pduObj => {
if (!pduObj.cmdName.startsWith('bind_')) return;
const variant = variantFromSystemId(paramText(pduObj.params.system_id));
if (variant) upstreamSessions.set(variant, session);
});
session.on('sms', sms => {
const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant;
if (variant) upstreamSms.push({ sms, variant });
void (async () => {
await sms.sendResp();
if (sms.dlr) {
await delay(150);
await sms.sendDlr('DELIVERED');
}
})();
});
});
async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise<Session> {
const found = await waitFor(() => upstreamSessions.get(variant), budget);
assert.ok(found, `Jasmin's ${variant} connector never bound to our fake upstream within ${String(budget)}ms`);
return found;
}
type DlrCallback = { id: string; messageStatus: string };
const dlrCallbacks: DlrCallback[] = [];
const httpServer = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://node');
if (url.pathname === '/dlr') {
dlrCallbacks.push({
id: url.searchParams.get('id') ?? '',
messageStatus: url.searchParams.get('message_status') ?? '',
});
res.writeHead(200);
res.end();
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>(resolve => { httpServer.listen(CALLBACK_PORT, resolve); });
after(async () => {
await upstreamServer.close();
await new Promise<void>(resolve => { httpServer.close(() => { resolve(); }); });
});
/** dlr-level 1 (SMSC-ack) fires once, immediately, with message_status ESME_ROK - not a terminal
* state - so a caller after the final DELIVRD needs dlr-level 3 (both) and its own status filter. */
async function waitForDlrCallback(id: string, status: string, budget = 15_000): Promise<DlrCallback> {
const found = await waitFor(() => dlrCallbacks.find(callback => callback.id === id && callback.messageStatus === status), budget);
assert.ok(found, `no dlr callback for id=${id} status=${status} arrived (seen: ${JSON.stringify(dlrCallbacks)})`);
return found;
}
/** Jasmin's HTTP send API (`/send`): `to` must be digits only, `content` is the message body. */
async function httpSend(params: Record<string, string>): Promise<{ body: string; status: number }> {
const url = new URL(`http://${HTTP_API_HOST}:${String(HTTP_API_PORT)}/send`);
url.search = new URLSearchParams({ password: PASSWORD, username: USERNAME, ...params }).toString();
const response = await fetch(url, { method: 'POST' });
const body = await response.text();
return { body, status: response.status };
}
const sarReference = new ConcatReference();
/** Splits into SAR segments: `splitMessage()`'s own encoding/chunking, its UDH stripped back off. */
function sarPayloads(message: string, encoding?: EncodingName): Buffer[] {
const reference = sarReference.next();
const segments = splitMessage(message, encoding === undefined ? { reference } : { encoding, reference });
return segments.length > 1 ? segments.map(segment => segment.subarray(6)) : segments;
}
/** Pushes a long MO into Jasmin over `session` (Jasmin's smppc connector bound to us) as
* `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` segments - Jasmin's own documented
* default MT segmentation (target 3). */
async function sendSarMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
const payloads = sarPayloads(opts.message);
const refNum = sarReference.next();
for (const [index, payload] of payloads.entries()) {
const sent = await session.send({
cmdName: 'deliver_sm',
params: {
destination_addr: opts.to,
short_message: payload,
source_addr: opts.from,
},
tlvs: {
sar_msg_ref_num: { tagValue: refNum },
sar_segment_seqnum: { tagValue: index + 1 },
sar_total_segments: { tagValue: payloads.length },
},
});
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
}
}
/** As `sendSarMo`, but UDH concatenation (esm_class 0x40) - the same shape `sendSms()` emits, and
* Jasmin's documented "older system compatibility" alternative. */
async function sendUdhMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
const reference = sarReference.next();
const segments = splitMessage(opts.message, { reference });
const multipart = segments.length > 1;
for (const segment of segments) {
const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'ASCII', multipart });
const sent = await session.send({ cmdName: 'deliver_sm', params });
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
}
}
/** A `deliver_sm` with `sm_length` 0 and the body in `message_payload` (target 2). */
async function sendMessagePayloadMo(session: Session, opts: { from: string; message: string; to: string }): ReturnType<Session['send']> {
return session.send({
cmdName: 'deliver_sm',
params: {
destination_addr: opts.to,
short_message: Buffer.alloc(0),
source_addr: opts.from,
},
tlvs: {
// The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM.
message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer },
},
});
}
describe('C1 - bind, enquire_link, unbind', () => {
test('binds transceiver, sees Jasmin\'s own enquire_link, unbinds clean', async () => {
// Jasmin's own enquireLinkTimerSecs (30) is an idle timer, not a strict period: our client's
// own default 20s keepalive counts as activity and resets it, so Jasmin's probe never has a
// chance to fire on its own. Disabling ours (and widening idleTimeout, which defaults off
// enquireLinkInterval and would otherwise become 0) leaves the link quiet long enough to see it.
const { err, session } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 });
assert.equal(err, undefined);
assert.ok(session);
const incoming: PduObject[] = [];
const closes: true[] = [];
const sessionErrors: Error[] = [];
session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
session.on('close', () => { closes.push(true); });
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
// enquireLinkTimerSecs is 30 in Jasmin's default [smpp-server] config.
const theirs = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'enquire_link'), 35_000);
assert.ok(theirs, 'expected Jasmin to send its own enquire_link within 35s');
const ours = await session.send({ cmdName: 'enquire_link' });
assert.equal(ours.err, undefined);
assert.ok(ours.pduObj);
assert.equal(ours.pduObj.cmdStatus, 'ESME_ROK');
const unbound = await session.unbind();
assert.equal(unbound.err, undefined);
assert.ok(await waitFor(() => (closes.length > 0 ? true : undefined), 5000), 'expected a clean close after unbind');
assert.deepEqual(sessionErrors, []);
});
test('binds transmitter', async t => {
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
assert.equal(session.boundAs, 'transmitter');
});
test('binds receiver', async t => {
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
assert.equal(session.boundAs, 'receiver');
});
});
describe('C13 - maxOutstanding 1 with 10 parallel sends', () => {
test('every send is answered, none lost, order preserved at the fake upstream', async t => {
await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const before = upstreamSms.length;
const texts = Array.from({ length: 10 }, (_, i) => `c13-order-${String(i).padStart(2, '0')}`);
const results = await Promise.all(texts.map(async message => session.sendSms({ from: FROM, message, to: TO })));
const ids: string[] = [];
for (const result of results) {
assert.equal(result.err, undefined);
assert.equal(result.smsIds.length, 1);
const [smsId] = result.smsIds;
assert.ok(smsId);
ids.push(smsId);
}
assert.equal(new Set(ids).size, ids.length, 'expected 10 distinct message ids, none lost or duplicated');
const arrivedOrder = await waitFor(() => {
const seen = upstreamSms.slice(before).filter(e => e.variant === 'main').map(e => e.sms.message);
return texts.every(text => seen.includes(text)) ? seen : undefined;
}, 60_000);
assert.ok(arrivedOrder, 'not all 10 messages reached the fake upstream');
const ordered = arrivedOrder.filter(m => texts.includes(m));
assert.deepEqual(ordered, texts, 'maxOutstanding:1 should serialise sends end to end, in order');
});
});
describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callback)', () => {
test('a message pushed through /send arrives as submit_sm at our server; our receipt fires Jasmin\'s DLR webhook', async () => {
const before = upstreamSms.length;
// No printf-style placeholders (unlike Kannel's %d/%F): DLRThrower appends its own fixed
// query args - id, level, message_status, connector - to this bare URL (confirmed from
// jasmin/routing/throwers.py). dlr-method=get puts them in the query string; POST (Jasmin's
// own default) would need a form-body reader instead.
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr`;
const sent = await httpSend({
content: 's7 http send test',
dlr: 'yes',
// Level 3 (both): level 1 alone fires once, immediately, with message_status ESME_ROK -
// the SMSC-ack, not a terminal state - so proving Jasmin parses our own receipt needs the
// terminal-level callback too, which only follows the sendDlr('DELIVERED') below.
'dlr-level': '3',
'dlr-method': 'get',
'dlr-url': dlrUrl,
from: FROM,
to: TO,
});
assert.match(sent.body, /Success/i);
const arrived = await waitFor(() => upstreamSms.slice(before).find(e => e.variant === 'main' && e.sms.message === 's7 http send test'), 60_000);
assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()');
// Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the
// shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback.
const msgidMatch = /Success "([^"]+)"/i.exec(sent.body);
const msgid = msgidMatch?.[1];
assert.ok(msgid, `expected /send's response to carry a message id: ${sent.body}`);
const ack = await waitForDlrCallback(msgid, 'ESME_ROK', 15_000);
assert.equal(ack.id, msgid);
const final = await waitForDlrCallback(msgid, 'DELIVRD', 15_000);
assert.equal(final.id, msgid);
});
test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `s7-long-${'p'.repeat(300)}`;
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? sms.length > 0, 'expected the long message to arrive whole or as recorded fragments');
});
test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `一😀${'q'.repeat(60)}`;
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? sms.length > 0, 'expected the UCS-2 message to arrive whole or as recorded fragments');
});
});
describe('C3+C7 - long MT through the fake upstream, receipts and id consistency', () => {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(-\d+)?$/i;
const cases: { encoding?: 'UCS2'; expectedSegments: number; label: string; message: string }[] = [
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: '€[]~single segment' },
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: `€[]~${'g'.repeat(200)}` },
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: `€[]~${'g'.repeat(400)}` },
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: `€[]~${'g'.repeat(1450)}` },
{ encoding: 'UCS2', expectedSegments: 2, label: '2-segment UCS2 with 一 and an emoji', message: `一😀${'x'.repeat(70)}` },
];
for (const testCase of cases) {
test(testCase.label, async t => {
await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD);
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const dlrs: { dlr: Dlr; pduObj: PduObject }[] = [];
const messageDlrs: unknown[] = [];
session.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); });
session.on('messageDlr', merged => { messageDlrs.push(merged); });
const sent = await session.sendSms({
dlr: true,
from: FROM,
message: testCase.message,
to: TO,
...(testCase.encoding ? { encoding: testCase.encoding } : {}),
});
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, testCase.expectedSegments);
for (const id of sent.smsIds) {
assert.ok(id, 'expected Jasmin to name a message id for every segment');
assert.match(id, uuidPattern, 'expected a UUID-shaped message id from Jasmin\'s submit_sm_resp');
// The full round trip - submit_sm to Jasmin's smpps, mtrouter, AMQP, the connector bind,
// our fake upstream's ack+DLR, AMQP again, DLRLookup, deliver_sm back - is slower than a
// single-segment send's, and visibly so under load; a generous budget beats a flaky one.
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === id), 25_000);
assert.ok(received, `no receipt for id ${id}`);
assert.equal(received.dlr.statusMsg, 'DELIVERED');
}
// Jasmin relays each segment as its own independent submit_sm to the connector (no MT-side
// UDH reassembly observed here - see findings), so the ids Jasmin hands back are whatever
// the fake upstream's own server() assigned per segment of ITS OWN reassembled view. That
// is this library's own <base>-<n> convention on both ends of this harness, which is why
// messageDlr can fire here - not evidence of Jasmin producing that convention itself; see
// findings/03-jasmin.md for what a real, independent upstream SMSC would hand back instead.
void messageDlrs;
});
}
});
describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => {
test('SAR-segmented deliver_sm from the fake upstream', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `sar-mo-${'m'.repeat(300)}`;
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
const fragments = sms.filter(s => s.message !== text && text.includes(s.message) && s.message !== '');
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole, 'expected the SAR segments to reassemble into one whole sms');
assert.deepEqual(fragments.map(s => s.message), [], 'no segment reaches the application on its own');
});
test('UDH-segmented deliver_sm from the fake upstream', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `udh-mo-${'n'.repeat(300)}`;
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
const fragments = sms.filter(s => text.includes(s.message) && s.message !== '');
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or UDH fragments to arrive');
});
});
describe('C8 (target 2) - message_payload with sm_length 0', () => {
test('a deliver_sm carrying message_payload instead of short_message', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = 'message-payload only, sm_length 0';
const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM });
assert.equal(pushed.err, undefined, 'expected Jasmin to accept a message_payload-only deliver_sm from its connector');
const arrived = await waitFor(() => sms.find(s => s.message === text), 5000);
await session.close({ signal: AbortSignal.abort() });
// Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the
// whole body has to reach the application from there.
assert.ok(arrived, 'expected the message_payload body to arrive as the sms text');
assert.equal(arrived.from, TO);
assert.equal(arrived.to, FROM);
});
});
describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => {
test('a receipt thrown as data_sm reaches the dlr event', async t => {
await waitForUpstreamSession('datasm');
const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const dlrs: Dlr[] = [];
const incomingDataSm: PduObject[] = [];
session.on('dlr', dlr => { dlrs.push(dlr); });
session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); });
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO });
assert.equal(sent.err, undefined);
const [smsId] = sent.smsIds;
const arrived = await waitFor(() => incomingDataSm[0], 15_000);
assert.ok(smsId);
assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)');
const dlr = await waitFor(() => dlrs.find(one => one.smsId === smsId), 10_000);
assert.ok(dlr, `no dlr for the receipt Jasmin threw as data_sm, id ${smsId}`);
assert.equal(dlr.statusMsg, 'DELIVERED');
});
});
describe('C11 - bind refusal and reconnect backoff', () => {
test('wrong password: one attempt, no retry', async () => {
const refusals: { cmdStatus: unknown }[] = [];
const log = {
debug: () => undefined,
error: () => undefined,
info: (msg: string, metadata?: Record<string, boolean | number | string>) => {
if (msg === 'client - bind refused') refusals.push({ cmdStatus: metadata?.cmdStatus });
},
verbose: () => undefined,
warn: () => undefined,
};
const { err, session } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } });
assert.ok(err);
assert.equal(session, undefined);
await delay(2000);
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
});
test('a rebind refused after a live link drops: backs off, never floods', async t => {
const options: Parameters<typeof client>[0] = {
host: PEER_HOST,
password: PASSWORD,
port: PEER_PORT,
reconnect: { maxDelay: 4000, minDelay: 1000 },
username: USERNAME,
};
const { err, session } = await client(options);
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const disconnectedAt: number[] = [];
session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
options.password = 'wrong-after-drop';
session.sock.destroy();
await delay(12_000);
assert.ok(disconnectedAt.length >= 2 && disconnectedAt.length <= 8, `expected a handful of attempts, got ${String(disconnectedAt.length)}`);
for (let i = 1; i < disconnectedAt.length; i++) {
const previous = disconnectedAt[i - 1];
const current = disconnectedAt[i];
assert.ok(previous !== undefined && current !== undefined);
assert.ok(current - previous >= 150, 'expected each retry to wait at least close to minDelay');
}
});
});
describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => {
test('flooding submits past the quota gets an err naming the status; the session stays bound; a later send works', async t => {
await waitForUpstreamSession('main');
const { err, session } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const results = await Promise.all(
Array.from({ length: 15 }, async (_unused, index) => session.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })),
);
const refused = results.filter(r => r.err !== undefined);
assert.ok(refused.length > 0, 'expected the 0.1/s quota to refuse at least one of 15 parallel sends');
assert.match(refused[0]?.err?.message ?? '', /ESME_RTHROTTLED/);
const keepalive = await session.send({ cmdName: 'enquire_link' });
assert.equal(keepalive.err, undefined);
assert.ok(keepalive.pduObj);
assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK');
// 0.1/s is one slot every 10s, so a single fixed delay is either wasteful or flaky - polling
// finds the next open slot instead of guessing it.
const deadline = Date.now() + 25_000;
let later: Awaited<ReturnType<typeof session.sendSms>> | undefined;
while (!later && Date.now() < deadline) {
const attempt = await session.sendSms({ from: FROM, message: 'after the burst', to: TO });
if (!attempt.err) later = attempt;
else await delay(500);
}
assert.ok(later, 'expected a later send to succeed once the quota\'s next slot opened');
});
});