import assert from 'node:assert/strict'; import test, { after, describe } from 'node:test'; import type { Session } from '../src/session.ts'; import type { Sms } from '../src/sms.ts'; import { encodings } from '../src/defs/encodings.ts'; import { paramText } from '../src/defs/types.ts'; import { isCommand, server } from '../src/index.ts'; const DRIVER = process.env.PYTHON_DRIVER ?? 'python:8080'; const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); const REFUSED_DEST = 'REFUSEME'; function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } async function waitFor(get: () => T | undefined, budget = 8000): Promise { const deadline = Date.now() + budget; let value = get(); while (value === undefined && Date.now() < deadline) { await delay(50); value = get(); } return value; } type JsonBody = Record; async function post(path: string, body: JsonBody): Promise { const res = await fetch(`http://${DRIVER}${path}`, { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, method: 'POST', }); return res.json() as Promise; } async function get(path: string): Promise { const res = await fetch(`http://${DRIVER}${path}`); return res.json() as Promise; } async function bindReader(name: string, opts: Partial = {}): Promise { const bound = await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, ...opts }); assert.equal(bound.ok, true, JSON.stringify(bound)); const started = await post('/startReader', { autoSendEnquireLink: true, name }); assert.equal(started.ok, true, JSON.stringify(started)); } // --- Shared infra: one server() for the whole file, one session per named python-smpplib bind, // tracked by the system_id the driver binds with - the same pattern as kannel.test.ts's variant. --- const allSms: { sms: Sms; systemId: string }[] = []; const systemIdBySession = new Map(); const { err: serverErr, server: smpp } = await server({ onRequest: async (session, pduObj) => { if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false; await session.sendReturn(pduObj, 'ESME_RTHROTTLED'); return true; }, 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; systemIdBySession.set(session, paramText(pduObj.params.system_id)); }); session.on('sms', sms => { const systemId = systemIdBySession.get(session); if (systemId) allSms.push({ sms, systemId }); }); }); after(async () => { await smppServer.close(); }); function sessionFor(systemId: string): Session | undefined { return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId); } async function waitForSession(systemId: string, budget = 8000): Promise { const found = await waitFor(() => sessionFor(systemId), budget); assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`); return found; } async function waitForSms(systemId: string, message: string, budget = 8000): Promise { const found = await waitFor( () => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms, budget, ); assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`); return found; } type ReceivedEntry = { dataCoding: number; esmClass: number; from: string; hex: string; text: string; to: string }; async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => boolean, budget = 8000): Promise { const found = await waitFor(async () => { const status = await get(`/received?name=${name}`); const list = status.received as ReceivedEntry[]; return list.find(predicate); }, budget); assert.ok(found, `no matching received entry for ${name} within ${String(budget)}ms`); return found; } type AckResult = { messageId?: string; status?: number }; // A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so // /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards. async function waitForAck(name: string, sequence: number, budget = 8000): Promise { const found = await waitFor(async () => { const status = await get(`/ack?name=${name}&sequence=${String(sequence)}`); return status.found ? status : undefined; }, budget); assert.ok(found, `no ack for sequence ${String(sequence)} (${name}) within ${String(budget)}ms`); return found; } async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise { const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII'; const buf = encodings[encName].encode(text); const sent = await session.send({ cmdName: 'deliver_sm', params: { data_coding: dataCoding, destination_addr: sms.from, short_message: buf, source_addr: sms.to }, }); assert.equal(sent.err, undefined); assert.ok(sent.pduObj); assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); } // The real GSM 03.38 basic table (128 unique chars), as this library decodes it. const ourTable = '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'; // python-smpplib's own gsm.GSM_CHARACTER_TABLE[:128] - identical except at 0x5F, a backtick where // the real table (and this library) has a section sign. const theirTable = '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà'; const ESC_INDEX = 27; const sendBasic = theirTable.slice(0, ESC_INDEX) + theirTable.slice(ESC_INDEX + 1); const expectBasic = ourTable.slice(0, ESC_INDEX) + ourTable.slice(ESC_INDEX + 1); const extensionChars = '€[]{}\\|~^'; describe('S11 - encodings (python-smpplib)', () => { test('GSM 03.38 basic table + extension table round trip (python -> server)', async () => { await bindReader('s11-basic'); const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-basic', text: sendBasic + extensionChars, to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); const sms = await waitForSms('s11-basic', expectBasic + extensionChars); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0); }); test('form feed (0x1B 0x0A), which smpplib\'s own gsm_encode() cannot build, round trips raw', async () => { await bindReader('s11-ff'); const sent = await post('/submit', { dataCoding: 0, extraHex: '1b0a', from: '46700000001', name: 's11-ff', text: 'before-', to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); const sms = await waitForSms('s11-ff', 'before-\f'); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0); }); test('Latin-1 round trip', async () => { await bindReader('s11-latin1'); const text = 'café £ ñ ¿¡ © ®'; const sent = await post('/submit', { dataCoding: 3, from: '46700000001', name: 's11-latin1', text, to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); const sms = await waitForSms('s11-latin1', text); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0); }); test('UCS-2 with 一 and an emoji round trip', async () => { await bindReader('s11-ucs2'); const text = '一😀hello'; const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-ucs2', text, to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); const sms = await waitForSms('s11-ucs2', text); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0); }); test('reverse direction: server sends GSM text back, python decodes it the same way', async () => { await bindReader('s11-echo-gsm'); const text = 'hello from the server'; const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-echo-gsm', text: 'seed', to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); const sms = await waitForSms('s11-echo-gsm', 'seed'); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0); const session = sessionFor('s11-echo-gsm'); assert.ok(session); await echoBack(session, sms, 0, text); const received = await waitForReceived('s11-echo-gsm', e => e.text === text); assert.equal(received.dataCoding, 0); }); test('reverse direction: server sends UCS-2 text back, python decodes it the same way', async () => { await bindReader('s11-echo-ucs2'); const seed = 'seed-ucs2'; const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-echo-ucs2', text: seed, to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); const sms = await waitForSms('s11-echo-ucs2', seed); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0); const session = sessionFor('s11-echo-ucs2'); assert.ok(session); const text = '一😀back'; await echoBack(session, sms, 8, text); const received = await waitForReceived('s11-echo-ucs2', e => e.text === text); assert.equal(received.dataCoding, 8); }); test('peer quirk, documented both ways: byte 0x5F is section sign here, backtick in smpplib\'s own table', async () => { await bindReader('s11-quirk'); // python encodes a literal backtick through its own gsm_encode(), landing on byte 0x5F - // this library decodes that byte to SECTION SIGN, the real GSM 03.38 value. const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-quirk', text: '`', to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); const sms = await waitForSms('s11-quirk', '§'); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0); // Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads // the same byte back as a backtick, not a section sign - the mismatch shows up both ways. const session = sessionFor('s11-quirk'); assert.ok(session); await echoBack(session, sms, 0, '§'); const received = await waitForReceived('s11-quirk', e => e.dataCoding === 0); assert.equal(received.text, '`'); }); }); describe('S2 - long messages (python-smpplib, UDH)', () => { for (const segments of [2, 3, 10]) { test(`${String(segments)} segments reassemble whole, each answered on arrival`, async () => { const name = `s2-udh-${String(segments)}`; await bindReader(name); const text = Array.from({ length: 153 * (segments - 1) + 10 }, (_, i) => String(i % 10)).join(''); const sent = await post('/submitLong', { dataCoding: 0, from: '46700000001', name, text, to: '46700000002' }); assert.equal(sent.ok, true, JSON.stringify(sent)); assert.equal(sent.parts, segments); const sms = await waitForSms(name, text, 15_000); assert.equal(sms.answeredOnArrival, true); const results = sent.results as { messageId: string; status: number }[]; assert.equal(results.length, segments); for (const [i, result] of results.entries()) { assert.equal(result.status, 0); assert.equal(result.messageId, `${sms.smsId}-${String(i + 1)}`); } }); } }); describe('Keepalive - python-smpplib\'s reactive enquire_link', () => { test('idle past idleTimeout (40s) with no keepalive: our server drops the session', async () => { const name = 'keepalive-none'; await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 50 }); const session = await waitForSession(name); const closed: unknown[] = []; session.on('close', () => { closed.push(undefined); }); const idle = await post('/idleSilent', { name, seconds: 45 }); assert.equal(idle.ok, true, JSON.stringify(idle)); assert.equal(idle.closed, true); assert.deepEqual(closed, [undefined]); }); test('idle past idleTimeout (40s) with auto_send_enquire_link: the link survives', async () => { const name = 'keepalive-auto'; await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 10 }); await waitForSession(name); await post('/startReader', { autoSendEnquireLink: true, name }); const closed: unknown[] = []; const session = sessionFor(name); assert.ok(session); session.on('close', () => { closed.push(undefined); }); await delay(45_000); const status = await get(`/status?name=${name}`); assert.equal(status.readerRunning, true); assert.equal(status.readerError, null); assert.deepEqual(closed, []); assert.ok(sessionFor(name), 'session should still be bound'); }); }); describe('Refusals via onRequest', () => { test('a refusing status through onRequest is surfaced to python-smpplib, not a hang or close', async () => { const name = 'refusal'; await bindReader(name); // Refused through onRequest, before reassembly and before the sms event, so this is answered // without any sendResp() call - unlike every other submit in this file. const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST }); assert.equal(refused.ok, true, JSON.stringify(refused)); assert.equal((await waitForAck(name, refused.sequence as number)).status, 0x58); // ESME_RTHROTTLED const link = await post('/enquireLink', { name }); assert.equal(link.ok, true); const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'still works', to: '46700000002' }); assert.equal(after1.ok, true, JSON.stringify(after1)); const sms = await waitForSms(name, 'still works'); assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck(name, after1.sequence as number)).status, 0); }); });