638 lines
22 KiB
TypeScript
638 lines
22 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import test, { after, describe } from 'node:test';
|
|
import type { MessageState } from '../src/defs/constants.ts';
|
|
import type { Dlr } from '../src/dlr.ts';
|
|
import type { Session } from '../src/session.ts';
|
|
import type { Sms } from '../src/sms.ts';
|
|
import { ConcatReference } from '../src/udh.ts';
|
|
import { consts } from '../src/defs/constants.ts';
|
|
import { detect, encodings } from '../src/defs/encodings.ts';
|
|
import { paramText } from '../src/defs/types.ts';
|
|
import { server } from '../src/server.ts';
|
|
import { splitMessage } from '../src/message.ts';
|
|
import { submitSmParams } from '../src/send-sms.ts';
|
|
|
|
// smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server.
|
|
const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013';
|
|
const IV33_SMSBOX = process.env.IV33_SMSBOX ?? 'kannel-iv33-smsbox:13013';
|
|
const MAXP1_SMSBOX = process.env.MAXP1_SMSBOX ?? 'kannel-maxp1-smsbox:13013';
|
|
const NOTRX_SMSBOX = process.env.NOTRX_SMSBOX ?? 'kannel-notrx-smsbox:13013';
|
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
|
const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080');
|
|
const SENDSMS_USER = 'tester';
|
|
const SENDSMS_PASS = 'testerpw';
|
|
|
|
type Variant = 'iv33' | 'main' | 'maxp1' | 'notrx';
|
|
|
|
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 = 5000): Promise<T | undefined> {
|
|
const deadline = Date.now() + budget;
|
|
let value = get();
|
|
|
|
while (value === undefined && Date.now() < deadline) {
|
|
await delay(20);
|
|
value = get();
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
// --- Shared infra: one long-lived server() and one HTTP callback listener for the whole file,
|
|
// since every Kannel variant dials in and keeps retrying from container start, independent of
|
|
// when this file's tests run. ---
|
|
|
|
type MoCallback = { coding: string; from: string; text: string; to: string; udh: string };
|
|
type DlrCallback = { answer: string; id: string; type: string };
|
|
|
|
const moCallbacks = new Map<Variant, MoCallback[]>();
|
|
const dlrCallbacks: DlrCallback[] = [];
|
|
|
|
function variantFromPath(pathname: string): Variant | undefined {
|
|
if (pathname === '/mo') return 'main';
|
|
if (pathname === '/mo/iv33') return 'iv33';
|
|
if (pathname === '/mo/maxp1') return 'maxp1';
|
|
if (pathname === '/mo/notrx') return 'notrx';
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function rawQueryValue(rawUrl: string, key: string): string {
|
|
const match = new RegExp(`[?&]${key}=([^&]*)`).exec(rawUrl);
|
|
|
|
return match?.[1] ?? '';
|
|
}
|
|
|
|
function percentDecodeBytes(raw: string): Buffer {
|
|
const bytes: number[] = [];
|
|
|
|
for (let i = 0; i < raw.length; i++) {
|
|
if (raw[i] === '%' && i + 2 < raw.length) {
|
|
bytes.push(Number.parseInt(raw.slice(i + 1, i + 3), 16));
|
|
i += 2;
|
|
} else {
|
|
bytes.push(raw.charCodeAt(i));
|
|
}
|
|
}
|
|
|
|
return Buffer.from(bytes);
|
|
}
|
|
|
|
// Kannel's %a decodes GSM text to a normal string before percent-escaping it, but for UCS-2
|
|
// (coding=2) it escapes the raw big-endian bytes instead - URLSearchParams decodes percent-escapes
|
|
// as UTF-8, which turns those raw bytes into mojibake, so coding=2 needs a byte-level percent-decode
|
|
// through our own UCS2 decoder instead.
|
|
function moText(rawUrl: string, url: URL): string {
|
|
if (url.searchParams.get('coding') !== '2') return url.searchParams.get('text') ?? '';
|
|
|
|
return encodings.UCS2.decode(percentDecodeBytes(rawQueryValue(rawUrl, 'text')));
|
|
}
|
|
|
|
const httpServer = http.createServer((req, res) => {
|
|
const url = new URL(req.url ?? '/', 'http://node');
|
|
|
|
if (url.pathname === '/dlr') {
|
|
dlrCallbacks.push({
|
|
answer: url.searchParams.get('answer') ?? '',
|
|
id: url.searchParams.get('id') ?? '',
|
|
type: url.searchParams.get('type') ?? '',
|
|
});
|
|
res.writeHead(200);
|
|
res.end();
|
|
|
|
return;
|
|
}
|
|
|
|
const variant = variantFromPath(url.pathname);
|
|
|
|
if (variant) {
|
|
const list = moCallbacks.get(variant) ?? [];
|
|
|
|
list.push({
|
|
coding: url.searchParams.get('coding') ?? '',
|
|
from: url.searchParams.get('from') ?? '',
|
|
text: moText(req.url ?? '', url),
|
|
to: url.searchParams.get('to') ?? '',
|
|
udh: url.searchParams.get('udh') ?? '',
|
|
});
|
|
moCallbacks.set(variant, list);
|
|
res.writeHead(200);
|
|
res.end();
|
|
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end();
|
|
});
|
|
|
|
await new Promise<void>(resolve => { httpServer.listen(CALLBACK_PORT, resolve); });
|
|
|
|
function variantFromSystemId(systemId: string): Variant | undefined {
|
|
if (systemId === 'kannel') return 'main';
|
|
if (systemId === 'kannel-iv33') return 'iv33';
|
|
if (systemId === 'kannel-maxp1') return 'maxp1';
|
|
if (systemId === 'kannel-notrx') return 'notrx';
|
|
|
|
return undefined;
|
|
}
|
|
|
|
const allSms: { sms: Sms; variant: Variant }[] = [];
|
|
const allDlrs: { dlr: Dlr; variant: Variant }[] = [];
|
|
const bindPdus: { params: Record<string, unknown>; variant: Variant }[] = [];
|
|
|
|
const { err: serverErr, server: smpp } = await server({
|
|
authenticate: ({ password, systemId }) => {
|
|
if (password !== 'kannelpw') return false;
|
|
|
|
const variant = variantFromSystemId(systemId);
|
|
|
|
return variant ? { userData: { variant } } : false;
|
|
},
|
|
idleTimeout: 40_000,
|
|
port: SMPP_PORT,
|
|
});
|
|
|
|
assert.equal(serverErr, undefined);
|
|
assert.ok(smpp);
|
|
|
|
const smppServer = smpp;
|
|
|
|
smppServer.on('session', session => {
|
|
session.on('incomingPduObj', pduObj => {
|
|
if (!pduObj.cmdName.startsWith('bind_')) return;
|
|
|
|
const variant = variantFromSystemId(paramText(pduObj.params.system_id));
|
|
|
|
if (variant) bindPdus.push({ params: pduObj.params, variant });
|
|
});
|
|
|
|
session.on('sms', sms => {
|
|
const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
|
|
|
|
if (variant) allSms.push({ sms, variant });
|
|
});
|
|
|
|
session.on('dlr', dlr => {
|
|
const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
|
|
|
|
if (variant) allDlrs.push({ dlr, variant });
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
await smppServer.close();
|
|
await new Promise<void>(resolve => { httpServer.close(() => { resolve(); }); });
|
|
});
|
|
|
|
function sessionsFor(variant: Variant): Session[] {
|
|
return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant);
|
|
}
|
|
|
|
async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise<Session[]> {
|
|
const found = await waitFor(() => (sessionsFor(variant).length >= count ? sessionsFor(variant) : undefined), budget);
|
|
|
|
assert.ok(found, `no ${String(count)} session(s) bound for variant ${variant} within ${String(budget)}ms`);
|
|
|
|
return found;
|
|
}
|
|
|
|
// Kannel's sendsms answers 202 with a body of "0: Accepted for delivery" or "3: Queued for later
|
|
// delivery" - the HTTP status is never 200 (Table 7-16 of the user guide).
|
|
async function sendsms(host: string, params: Record<string, string>): Promise<{ body: string; status: number }> {
|
|
const url = new URL(`http://${host}/cgi-bin/sendsms`);
|
|
|
|
url.search = new URLSearchParams({ password: SENDSMS_PASS, username: SENDSMS_USER, ...params }).toString();
|
|
|
|
const response = await fetch(url);
|
|
const body = await response.text();
|
|
|
|
assert.equal(response.status, 202);
|
|
assert.match(body, /^[03]: /);
|
|
|
|
return { body, status: response.status };
|
|
}
|
|
|
|
/** The next incoming `sms` for a variant carrying `message`, polling past ones that don't match. */
|
|
async function waitForSms(variant: Variant, message: string, budget = 8000): Promise<Sms> {
|
|
const found = await waitFor(
|
|
() => allSms.find(entry => entry.variant === variant && entry.sms.message === message)?.sms,
|
|
budget,
|
|
);
|
|
|
|
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for variant ${variant}`);
|
|
|
|
return found;
|
|
}
|
|
|
|
async function waitForMoCallback(variant: Variant, text: string, budget = 8000): Promise<MoCallback> {
|
|
const found = await waitFor(
|
|
() => moCallbacks.get(variant)?.find(callback => callback.text === text),
|
|
budget,
|
|
);
|
|
|
|
assert.ok(found, `no MO callback carrying ${JSON.stringify(text)} arrived for variant ${variant}`);
|
|
|
|
return found;
|
|
}
|
|
|
|
async function waitForDlrCallback(id: string, type: string, budget = 8000): Promise<DlrCallback> {
|
|
const found = await waitFor(
|
|
() => dlrCallbacks.find(callback => callback.id === id && callback.type === type),
|
|
budget,
|
|
);
|
|
|
|
assert.ok(found, `no dlr callback id=${id} type=${type} arrived (seen: ${JSON.stringify(dlrCallbacks)})`);
|
|
|
|
return found;
|
|
}
|
|
|
|
/** Builds a `deliver_sm` per segment the way `session.sendSms()` builds `submit_sm` - see the MO
|
|
* describe block for why this bypasses `sendSms()` itself. */
|
|
async function sendMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
|
|
const encoding = detect(opts.message);
|
|
const reference = moReference.next();
|
|
const segments = splitMessage(opts.message, { encoding, 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, 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');
|
|
}
|
|
}
|
|
|
|
const moReference = new ConcatReference();
|
|
|
|
describe('kannel main variant - bind', () => {
|
|
test('binds transceiver 34, defaults addr_ton/npi to 0, carries our system_type', async () => {
|
|
await waitForSessions('main', 1);
|
|
|
|
const bind = await waitFor(() => bindPdus.find(entry => entry.variant === 'main'));
|
|
|
|
assert.ok(bind);
|
|
assert.equal(bind.params.system_type, 'kannel-esme');
|
|
assert.equal(bind.params.interface_version, 0x34);
|
|
assert.equal(bind.params.addr_ton, 0);
|
|
assert.equal(bind.params.addr_npi, 0);
|
|
assert.equal(bind.params.address_range, '');
|
|
});
|
|
});
|
|
|
|
describe('S1 - MT from Kannel with delivery reports', () => {
|
|
for (const status of ['DELIVERED', 'UNDELIVERABLE', 'EXPIRED', 'ENROUTE'] as MessageState[]) {
|
|
test(`dlr-mask=31 round trip settles as ${status}`, async () => {
|
|
await waitForSessions('main', 1);
|
|
|
|
const text = `s1-${status.toLowerCase()}`;
|
|
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
|
|
|
|
await sendsms(MAIN_SMSBOX, {
|
|
'dlr-mask': '31',
|
|
'dlr-url': dlrUrl,
|
|
from: '46701113311',
|
|
text,
|
|
to: '46709771337',
|
|
});
|
|
|
|
const sms = await waitForSms('main', text);
|
|
|
|
assert.equal(sms.from, '46701113311');
|
|
assert.equal(sms.to, '46709771337');
|
|
assert.equal(sms.dlr, true);
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
|
|
// dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt.
|
|
const submitAck = await waitForDlrCallback(sms.smsId, '8');
|
|
|
|
assert.equal(submitAck.id, sms.smsId);
|
|
|
|
const report = await sms.sendDlr(status);
|
|
|
|
assert.equal(report.err, undefined);
|
|
|
|
// Kannel's %d for a settled message: DELIVERED 1, ENROUTE 4, UNDELIVERABLE 2 - but EXPIRED
|
|
// is its own bit (34 = 32|2), not folded into the generic failure code.
|
|
const finalType = status === 'DELIVERED' ? '1' : status === 'ENROUTE' ? '4' : status === 'EXPIRED' ? '34' : '2';
|
|
const final = await waitForDlrCallback(sms.smsId, finalType);
|
|
|
|
assert.equal(final.id, sms.smsId);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('long MT from Kannel', () => {
|
|
test('300-char GSM text reassembles whole', async () => {
|
|
await waitForSessions('main', 1);
|
|
|
|
const text = 'g'.repeat(300);
|
|
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
|
|
|
|
const sms = await waitForSms('main', text, 15_000);
|
|
|
|
assert.equal(sms.message, text);
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
});
|
|
|
|
test('UCS-2 text with 一 and an emoji reassembles whole', async () => {
|
|
await waitForSessions('main', 1);
|
|
|
|
const text = `一😀${'x'.repeat(140)}`;
|
|
await sendsms(MAIN_SMSBOX, { charset: 'UTF-8', coding: '2', from: '46701113311', text, to: '46709771337' });
|
|
|
|
const sms = await waitForSms('main', text, 15_000);
|
|
|
|
assert.equal(sms.message, text);
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
});
|
|
});
|
|
|
|
describe('S11 - GSM extension characters', () => {
|
|
test('€ [ ] ~ round trip through Kannel unpacked GSM7', async () => {
|
|
await waitForSessions('main', 1);
|
|
|
|
const text = '€[]~ok';
|
|
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
|
|
|
|
const sms = await waitForSms('main', text);
|
|
|
|
assert.equal(sms.message, text);
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
});
|
|
});
|
|
|
|
describe('MO to Kannel', () => {
|
|
test('session.sendSms() is refused by Kannel: submit_sm only flows ESME to SMSC', async () => {
|
|
const [session] = await waitForSessions('main', 1);
|
|
|
|
assert.ok(session);
|
|
|
|
const result = await session.sendSms({ from: '46701113311', message: 'mo via sendSms', to: '46709771337' });
|
|
|
|
assert.ok(result.err);
|
|
assert.match(result.err.message, /ESME_RINVCMDID/);
|
|
});
|
|
|
|
test('a deliver_sm carrying a single-segment GSM message reaches the sms-service once', async () => {
|
|
const [session] = await waitForSessions('main', 1);
|
|
|
|
assert.ok(session);
|
|
|
|
const text = 'mo single segment';
|
|
|
|
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
|
|
|
|
const callback = await waitForMoCallback('main', text);
|
|
|
|
// Two Kannel quirks, not this library's: %p prepends '+' to an international-TON address
|
|
// even though the wire address carried none, and %P reports smsbox's own `global-sender`
|
|
// rather than the deliver_sm's destination_addr (unset `my-number` on the smsc group).
|
|
assert.equal(callback.from, '+46709771337');
|
|
assert.equal(callback.to, '46700000000');
|
|
|
|
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
|
|
|
|
assert.equal(matching.length, 1);
|
|
});
|
|
|
|
test('a deliver_sm split over 3 GSM segments reaches the sms-service once, whole', async () => {
|
|
const [session] = await waitForSessions('main', 1);
|
|
|
|
assert.ok(session);
|
|
|
|
const text = 'm'.repeat(400);
|
|
|
|
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
|
|
|
|
const callback = await waitForMoCallback('main', text, 15_000);
|
|
|
|
assert.equal(callback.text, text);
|
|
|
|
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
|
|
|
|
assert.equal(matching.length, 1);
|
|
});
|
|
|
|
test('a deliver_sm split over UCS-2 segments reaches the sms-service once, whole', async () => {
|
|
const [session] = await waitForSessions('main', 1);
|
|
|
|
assert.ok(session);
|
|
|
|
const text = `一😀${'y'.repeat(200)}`;
|
|
|
|
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
|
|
|
|
const callback = await waitForMoCallback('main', text, 15_000);
|
|
|
|
assert.equal(callback.text, text);
|
|
|
|
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
|
|
|
|
assert.equal(matching.length, 1);
|
|
});
|
|
});
|
|
|
|
describe('S6 - wait-ack expiry and keepalive', () => {
|
|
// Runs before the wait-ack test below, which deliberately provokes a disconnect/reconnect on
|
|
// this same variant's session - a stable link is needed to observe the keepalive cleanly.
|
|
test('enquire_link every 5s keeps a 60s-idle session from hitting idleTimeout (40s)', async () => {
|
|
const [session] = await waitForSessions('main', 1);
|
|
|
|
assert.ok(session);
|
|
|
|
const closes: unknown[] = [];
|
|
|
|
session.on('close', () => { closes.push(undefined); });
|
|
|
|
await delay(60_000);
|
|
|
|
assert.deepEqual(closes, []);
|
|
});
|
|
|
|
test('a submit_sm answered past wait-ack (5s) - Kannel\'s reaction is recorded, not judged', async () => {
|
|
await waitForSessions('main', 1);
|
|
|
|
const text = 's6-slow-resp';
|
|
const before = allSms.filter(e => e.variant === 'main').length;
|
|
|
|
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
|
|
|
|
const sms = await waitForSms('main', text);
|
|
|
|
await delay(7000);
|
|
await sms.sendResp().catch(() => undefined);
|
|
|
|
// wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it
|
|
// room to rebind and possibly resend the same submit_sm on the new session.
|
|
await delay(4000);
|
|
|
|
const after = allSms.filter(e => e.variant === 'main' && e.sms.message === text);
|
|
|
|
assert.ok(after.length >= 1, 'the original sms is still on record');
|
|
// Recorded for findings, not asserted: whether a resend duplicated the sms is peer behaviour.
|
|
void before;
|
|
});
|
|
});
|
|
|
|
describe('iv33 variant - interface_version 0x33', () => {
|
|
test('binds at 0x33 and negotiates no optional params', async () => {
|
|
const [session] = await waitForSessions('iv33', 1);
|
|
|
|
assert.ok(session);
|
|
assert.equal(session.peerInterfaceVersion, 0x33);
|
|
assert.equal(session.acceptsOptionalParams(), false);
|
|
});
|
|
|
|
test('MT + DLR round trip still correlates with no TLVs on the receipt', async () => {
|
|
const [session] = await waitForSessions('iv33', 1);
|
|
|
|
assert.ok(session);
|
|
assert.equal(session.acceptsOptionalParams(), false);
|
|
|
|
const text = 'iv33 round trip';
|
|
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
|
|
|
|
await sendsms(IV33_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' });
|
|
|
|
const sms = await waitForSms('iv33', text);
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
await waitForDlrCallback(sms.smsId, '8');
|
|
await sms.sendDlr('DELIVERED');
|
|
await waitForDlrCallback(sms.smsId, '1');
|
|
});
|
|
});
|
|
|
|
describe('maxp1 variant - max-pending-submits 1', () => {
|
|
test('a burst of 20 sendsms calls all arrive, in order, all answered', async () => {
|
|
const [session] = await waitForSessions('maxp1', 1);
|
|
|
|
assert.ok(session);
|
|
|
|
// max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a
|
|
// time - answer each as it lands, or the whole burst stalls behind the first message.
|
|
session.on('sms', sms => { void sms.sendResp(); });
|
|
|
|
const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`);
|
|
|
|
// Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever
|
|
// order the OS schedules them, so only a request-then-response chain keeps send order
|
|
// meaningful - max-pending-submits=1 is exercised regardless, since 20 calls in a tight loop
|
|
// still outrun one-at-a-time SMPP submission.
|
|
for (const text of texts) {
|
|
await sendsms(MAXP1_SMSBOX, { from: '46701113311', text, to: '46709771337' });
|
|
}
|
|
|
|
const arrived = await waitFor(() => {
|
|
const got = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message);
|
|
|
|
return texts.every(text => got.includes(text)) ? got : undefined;
|
|
}, 20_000);
|
|
|
|
assert.ok(arrived, 'not all 20 burst messages arrived');
|
|
|
|
const ordered = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message).filter(m => texts.includes(m));
|
|
|
|
assert.deepEqual(ordered, texts);
|
|
});
|
|
});
|
|
|
|
describe('notrx variant - separate TX and RX binds', () => {
|
|
test('Kannel opens a transmitter bind and a receiver bind, both accepted', async () => {
|
|
const sessions = await waitForSessions('notrx', 2);
|
|
|
|
assert.deepEqual(sessions.map(s => s.boundAs).sort(), ['receiver', 'transmitter']);
|
|
});
|
|
|
|
test('submit_sm from Kannel arrives on the transmitter bind, is answered, nothing refused', async () => {
|
|
const sessions = await waitForSessions('notrx', 2);
|
|
const tx = sessions.find(s => s.boundAs === 'transmitter');
|
|
|
|
assert.ok(tx);
|
|
|
|
const text = 'notrx mt';
|
|
|
|
await sendsms(NOTRX_SMSBOX, { from: '46701113311', text, to: '46709771337' });
|
|
|
|
const sms = await waitForSms('notrx', text);
|
|
|
|
assert.equal(sms.session, tx);
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
});
|
|
|
|
test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => {
|
|
const sessions = await waitForSessions('notrx', 2);
|
|
const tx = sessions.find(s => s.boundAs === 'transmitter');
|
|
const rx = sessions.find(s => s.boundAs === 'receiver');
|
|
|
|
assert.ok(tx);
|
|
assert.ok(rx);
|
|
|
|
// sms.sendDlr() ties the receipt to the session the submit_sm arrived on (the TX bind), which
|
|
// cannot carry deliver_sm at all (see README, Bind direction) - documented behaviour, not a
|
|
// defect. A split-bind peer's receipt has to be sent on the RX session directly.
|
|
//
|
|
// session.send() is the library's unchecked raw passthrough (README), so this probes Kannel's
|
|
// own direction enforcement, not ours: Kannel answers ESME_ROK to a deliver_sm on its
|
|
// transmitter bind rather than refusing it - recorded as a peer quirk, not asserted as a spec
|
|
// violation this library must guard against.
|
|
const onTx = await tx.send({
|
|
cmdName: 'deliver_sm',
|
|
params: { destination_addr: '46701113311', short_message: 'nope', source_addr: '46709771337' },
|
|
});
|
|
|
|
assert.equal(onTx.err, undefined);
|
|
|
|
const text = 'notrx dlr target';
|
|
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
|
|
|
|
await sendsms(NOTRX_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' });
|
|
|
|
const sms = await waitForSms('notrx', text);
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
await waitForDlrCallback(sms.smsId, '8');
|
|
|
|
const receiptDate = '2609051200';
|
|
const receiptSent = await rx.send({
|
|
cmdName: 'deliver_sm',
|
|
params: {
|
|
destination_addr: sms.from,
|
|
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
|
short_message: `id:${sms.smsId} sub:001 dlvrd:001 submit date:${receiptDate} done date:${receiptDate} `
|
|
+ 'stat:DELIVRD err:000 text:',
|
|
source_addr: sms.to,
|
|
},
|
|
});
|
|
|
|
assert.equal(receiptSent.err, undefined);
|
|
assert.ok(receiptSent.pduObj);
|
|
assert.equal(receiptSent.pduObj.cmdStatus, 'ESME_ROK');
|
|
|
|
await waitForDlrCallback(sms.smsId, '1');
|
|
});
|
|
|
|
test('MO built on the receiver bind reaches the sms-service', async () => {
|
|
const sessions = await waitForSessions('notrx', 2);
|
|
const rx = sessions.find(s => s.boundAs === 'receiver');
|
|
|
|
assert.ok(rx);
|
|
|
|
const text = 'notrx mo on rx';
|
|
|
|
await sendMo(rx, { from: '46709771337', message: text, to: '46701113311' });
|
|
|
|
const callback = await waitForMoCallback('notrx', text);
|
|
|
|
assert.equal(callback.text, text);
|
|
});
|
|
});
|