Declare the alphabet a GSM message is actually written in (#100)
* Regression tests for the alphabet a GSM message declares * Declare the alphabet a GSM message is actually written in * Reflow the composing-by-hand paragraph * Drop the ASCII alias SMPP's flat table shares with the option's own * Bound the receipt test's wait and tighten the notes around it
This commit is contained in:
@@ -152,6 +152,7 @@ naming the behaviour.
|
|||||||
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
|
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
|
||||||
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
|
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
|
||||||
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
|
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
|
||||||
|
| GSM 03.38 declared as IA5 | `sendSms` resolves its encoding through `consts.ENCODING`, so a GSM body goes out under `data_coding` 0x01 — SMPP 3.4 5.2.19's IA5 (CCITT T.50), where `$` and `@` are STX and NUL |
|
||||||
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
|
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
|
||||||
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
|
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
|
||||||
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
|
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
|
||||||
@@ -670,6 +671,38 @@ Grouped by what each one constrains.
|
|||||||
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
|
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
|
||||||
own definition.
|
own definition.
|
||||||
|
|
||||||
|
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
|
||||||
|
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
|
||||||
|
through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT
|
||||||
|
T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's
|
||||||
|
STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the
|
||||||
|
same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written
|
||||||
|
under, as `unencodable()` is to whether one can carry a message: the mirror of
|
||||||
|
`encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected
|
||||||
|
one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is
|
||||||
|
exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a
|
||||||
|
`data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the
|
||||||
|
*SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee;
|
||||||
|
it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile
|
||||||
|
and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and
|
||||||
|
so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01
|
||||||
|
to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no
|
||||||
|
researched peer means IA5 by it. The two tables agree over most of the printable range and part at
|
||||||
|
0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed,
|
||||||
|
carriage return and escape are common to both — which is where a peer that did mean IA5 is
|
||||||
|
misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two
|
||||||
|
names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different
|
||||||
|
values and so the only one a reader could carry from the option's vocabulary into SMPP's flat
|
||||||
|
table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name.
|
||||||
|
Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the
|
||||||
|
section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding`
|
||||||
|
option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as
|
||||||
|
Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing
|
||||||
|
researched has found. Accepted: a message already in flight is unmoved — both codings resolve to
|
||||||
|
the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the
|
||||||
|
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
|
||||||
|
change reads exactly as it did.
|
||||||
|
|
||||||
### The session's life
|
### The session's life
|
||||||
|
|
||||||
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
||||||
|
|||||||
@@ -599,9 +599,11 @@ how binary payloads, hand-built user data headers and deliberately malformed bod
|
|||||||
|
|
||||||
Composing a message by hand takes the send-side pair: `unencodable(message, encoding)` gives
|
Composing a message by hand takes the send-side pair: `unencodable(message, encoding)` gives
|
||||||
`{ char, index }` for the first character an alphabet cannot carry and `undefined` where it carries
|
`{ char, index }` for the first character an alphabet cannot carry and `undefined` where it carries
|
||||||
them all, which is the check `sendSms()` makes before it encodes anything; `smppTime.encode(value)`
|
them all, which is the check `sendSms()` makes before it encodes anything;
|
||||||
returns `{ err, text }` for a `validity_period` or `schedule_delivery_time`, as `smppTime.decode()`
|
`dataCodingByEncoding[encoding]` is the `data_coding` this library writes each alphabet under, which
|
||||||
returns `{ err, date }` for one that arrived.
|
is what to put beside octets `encodeMessage()` handed back; `smppTime.encode(value)` returns
|
||||||
|
`{ err, text }` for a `validity_period` or `schedule_delivery_time`, as `smppTime.decode()` returns
|
||||||
|
`{ err, date }` for one that arrived.
|
||||||
|
|
||||||
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
||||||
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
||||||
@@ -640,6 +642,9 @@ promises and the rough edges taken off.
|
|||||||
- **The `error` event is `sessionError`** (and `serverError` on the server handle).
|
- **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
|
- **`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).
|
`larvitutils` one, and is silent by default. See [Logging](#logging).
|
||||||
|
- **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP
|
||||||
|
3.4 5.2.19 gives 0x01. Reach for `dataCodingByEncoding` where you meant the alphabet `sendSms()`
|
||||||
|
writes — that is 0x00, not this.
|
||||||
|
|
||||||
### Behaviour that changed on the wire
|
### Behaviour that changed on the wire
|
||||||
|
|
||||||
@@ -659,6 +664,10 @@ have worked around any of these, remove the workaround:
|
|||||||
GSM alphabet on a Latin-1 message that has no `data_coding` at all — that pair is refused now.
|
GSM alphabet on a Latin-1 message that has no `data_coding` at all — that pair is refused now.
|
||||||
Inbound, only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the
|
Inbound, only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the
|
||||||
whole 0xF0 coding group arrived as ordinary messages.
|
whole 0xF0 coding group arrived as ordinary messages.
|
||||||
|
- A GSM 03.38 message declared `data_coding` 0x01, which SMPP 3.4 5.2.19 defines as IA5 rather than
|
||||||
|
the alphabet those octets are in, so `$` and `@` reached a peer honouring the field as STX and NUL.
|
||||||
|
It goes out as 0x00, the SMSC default alphabet, and so does a receipt `sendDlr()` writes. Latin-1
|
||||||
|
and UCS2 are unmoved at 0x03 and 0x08, and an inbound 0x01 is still read as GSM 03.38.
|
||||||
- The multipart reference counter was shared by every session in the process.
|
- The multipart reference counter was shared by every session in the process.
|
||||||
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
|
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
|
||||||
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ describe('smppsim-transition - C4 intermediate then final', () => {
|
|||||||
const sent = await session.send({
|
const sent = await session.send({
|
||||||
cmdName: 'submit_sm',
|
cmdName: 'submit_sm',
|
||||||
params: {
|
params: {
|
||||||
data_coding: consts.ENCODING.ASCII,
|
data_coding: 0,
|
||||||
destination_addr: TO,
|
destination_addr: TO,
|
||||||
registered_delivery: 0x11,
|
registered_delivery: 0x11,
|
||||||
short_message: Buffer.from('transition test', 'latin1'),
|
short_message: Buffer.from('transition test', 'latin1'),
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const consts = {
|
|||||||
YEARS: 0x0E,
|
YEARS: 0x0E,
|
||||||
},
|
},
|
||||||
ENCODING: {
|
ENCODING: {
|
||||||
ASCII: 0x01,
|
|
||||||
BINARY: 0x04,
|
BINARY: 0x04,
|
||||||
CYRILLIC: 0x06,
|
CYRILLIC: 0x06,
|
||||||
EXTENDED_KANJI_JIS: 0x0D,
|
EXTENDED_KANJI_JIS: 0x0D,
|
||||||
|
|||||||
@@ -188,3 +188,13 @@ export function encodingByDataCoding(dataCoding: number): EncodingName {
|
|||||||
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
|
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
|
||||||
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
|
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `data_coding` an alphabet is written under, the mirror of `encodingByDataCoding()`. GSM 03.38
|
||||||
|
* takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5.
|
||||||
|
*/
|
||||||
|
export const dataCodingByEncoding: Readonly<Record<EncodingName, number>> = {
|
||||||
|
ASCII: 0x00,
|
||||||
|
LATIN1: 0x03,
|
||||||
|
UCS2: 0x08,
|
||||||
|
};
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ export { Session } from './session.ts';
|
|||||||
|
|
||||||
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
||||||
export { consts, constsById } from './defs/constants.ts';
|
export { consts, constsById } from './defs/constants.ts';
|
||||||
export { detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
|
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
|
||||||
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
||||||
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
||||||
export { types } from './defs/types.ts';
|
export { types } from './defs/types.ts';
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import type { Result } from './result.ts';
|
import type { Result } from './result.ts';
|
||||||
import type { EncodingName } from './defs/encodings.ts';
|
import type { EncodingName } from './defs/encodings.ts';
|
||||||
import { detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
|
||||||
import { consts, hasUdh } from './defs/constants.ts';
|
import { hasUdh } from './defs/constants.ts';
|
||||||
import { udhLength } from './udh.ts';
|
import { udhLength } from './udh.ts';
|
||||||
|
|
||||||
/** A single SMS carries 1120 bits, whatever the alphabet. */
|
/** A single SMS carries 1120 bits, whatever the alphabet. */
|
||||||
@@ -35,7 +35,7 @@ export function encodeBody(
|
|||||||
if (dataCoding === undefined) {
|
if (dataCoding === undefined) {
|
||||||
const detected = encodeMessage(text);
|
const detected = encodeMessage(text);
|
||||||
|
|
||||||
return { buffer: detected.buffer, dataCoding: consts.ENCODING[detected.encoding] };
|
return { buffer: detected.buffer, dataCoding: dataCodingByEncoding[detected.encoding] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoding = encodingByDataCoding(dataCoding);
|
const encoding = encodingByDataCoding(dataCoding);
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
|
|||||||
import type { SmsIdNotation } from './sms-id.ts';
|
import type { SmsIdNotation } from './sms-id.ts';
|
||||||
import { UnansweredError } from './unanswered-error.ts';
|
import { UnansweredError } from './unanswered-error.ts';
|
||||||
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
||||||
import { detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
|
||||||
import { namedValue } from './error-from.ts';
|
import { namedValue } from './error-from.ts';
|
||||||
import { normaliseSmsId } from './sms-id.ts';
|
import { normaliseSmsId } from './sms-id.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { paramText } from './defs/types.ts';
|
||||||
@@ -89,7 +89,7 @@ function addressTon(address: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dataCodingFor(encoding: EncodingName, flash: boolean): number {
|
function dataCodingFor(encoding: EncodingName, flash: boolean): number {
|
||||||
if (!flash) return consts.ENCODING[encoding];
|
if (!flash) return dataCodingByEncoding[encoding];
|
||||||
|
|
||||||
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
|
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
|
||||||
return encoding === 'UCS2' ? 0x18 : 0x10;
|
return encoding === 'UCS2' ? 0x18 : 0x10;
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
|
import { client } from '../src/client.ts';
|
||||||
|
import { closeAfter } from './teardown.ts';
|
||||||
|
import { consts } from '../src/defs/constants.ts';
|
||||||
|
import { decodeMessage } from '../src/message.ts';
|
||||||
|
import { dlrFromPdu } from '../src/dlr.ts';
|
||||||
|
import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
|
||||||
|
import { objToPdu, pduToObj } from '../src/pdu.ts';
|
||||||
|
import { paramNumber } from '../src/defs/types.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
import type { PduObject } from '../src/pdu.ts';
|
||||||
|
|
||||||
|
const from = '46701113311';
|
||||||
|
const to = '46709771337';
|
||||||
|
|
||||||
|
/** GSM 03.38 puts $ at 0x02 and @ at 0x00, where IA5 has STX and NUL. */
|
||||||
|
const bothTables = 'Cost 5$ @home';
|
||||||
|
|
||||||
|
/** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */
|
||||||
|
const byTheSpecsTable: Record<number, (octets: Buffer) => string> = {
|
||||||
|
// The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38.
|
||||||
|
0x00: octets => encodings.ASCII.decode(octets),
|
||||||
|
// IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80.
|
||||||
|
[consts.ENCODING.IA5]: octets => octets.toString('latin1'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */
|
||||||
|
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error('waited 5000 ms for an event that never fired'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
register(value => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitted(octets: Buffer[]): PduObject[] {
|
||||||
|
return octets.map(pdu => {
|
||||||
|
const { pduObj } = pduToObj(pdu);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
return pduObj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredBy(pduObj: PduObject): number {
|
||||||
|
return paramNumber(pduObj.params.data_coding, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliver_sm carrying `body` under `dataCoding`, as a peer answering our own send would write it. */
|
||||||
|
function delivered(body: Buffer | string, dataCoding: number, esmClass: number): PduObject {
|
||||||
|
const { buffer } = objToPdu({
|
||||||
|
cmdName: 'deliver_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: dataCoding,
|
||||||
|
destination_addr: from,
|
||||||
|
esm_class: esmClass,
|
||||||
|
short_message: body,
|
||||||
|
source_addr: to,
|
||||||
|
},
|
||||||
|
seqNr: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
return pduObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the alphabet a message declares is the one its octets are written in', () => {
|
||||||
|
test('sends GSM 03.38 under data_coding 0x00, the SMSC default alphabet', async t => {
|
||||||
|
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de97-7caa-9d26-e6d50f5c4888'] });
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
const sent = await session.sendSms({ from, message: bothTables, to });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x00]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps $ and @ for a peer that honours the declaration, where IA5 read STX and NUL', async t => {
|
||||||
|
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de98-7d24-9542-e652e0d3761c'] });
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
|
||||||
|
assert.equal((await session.sendSms({ from, message: bothTables, to })).err, undefined);
|
||||||
|
|
||||||
|
const [pduObj] = submitted(smsc.octets);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
const octets = pduObj.shortMessageOctets;
|
||||||
|
|
||||||
|
assert.ok(octets);
|
||||||
|
assert.equal(octets.toString('hex'), '436f73742035022000686f6d65');
|
||||||
|
|
||||||
|
const read = byTheSpecsTable[declaredBy(pduObj)];
|
||||||
|
|
||||||
|
assert.ok(read, 'the coding declared must be one SMPP 3.4 5.2.19 names an alphabet for');
|
||||||
|
assert.equal(read(octets), bothTables);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves Latin-1 at 0x03 and UCS2 at 0x08, the codings those alphabets always had', async t => {
|
||||||
|
const smsc = await dummySmsc(t, {
|
||||||
|
messageIds: ['01a08779-de99-7fa5-bcac-18feef55aeee', '01a08779-de99-72ec-8dbb-9365a00158c3'],
|
||||||
|
});
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
|
||||||
|
assert.equal((await session.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined);
|
||||||
|
assert.equal((await session.sendSms({ from, message: 'あいう', to })).err, undefined);
|
||||||
|
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x03, 0x08]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too.
|
||||||
|
test('declares 0x00 on a receipt it writes itself', async t => {
|
||||||
|
const { err, server: smpp } = await server({ port: 0 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
closeAfter(t, smpp);
|
||||||
|
|
||||||
|
smpp.on('session', peer => peer.on('sms', async sms => {
|
||||||
|
await sms.sendResp();
|
||||||
|
await sms.sendDlr('DELIVERED');
|
||||||
|
}));
|
||||||
|
|
||||||
|
const connected = await client({ port: smpp.port, reconnect: false });
|
||||||
|
|
||||||
|
assert.equal(connected.err, undefined);
|
||||||
|
assert.ok(connected.session);
|
||||||
|
closeAfter(t, connected.session);
|
||||||
|
|
||||||
|
const session = connected.session;
|
||||||
|
const reported = once<PduObject>(resolve => {
|
||||||
|
session.on('dlr', (_report, pduObj) => { resolve(pduObj); });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((await session.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined);
|
||||||
|
assert.equal(declaredBy(await reported), 0x00);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The low-level surface settles data_coding off the same detection, so it carried the same defect.
|
||||||
|
test('settles a detected string body at 0x00 where the caller named no data_coding', () => {
|
||||||
|
const built = objToPdu({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: { destination_addr: to, short_message: bothTables, source_addr: from },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(built.buffer);
|
||||||
|
|
||||||
|
const { pduObj } = pduToObj(built.buffer);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
assert.equal(declaredBy(pduObj), 0x00);
|
||||||
|
assert.equal(pduObj.shortMessageOctets?.toString('hex'), '436f73742035022000686f6d65');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what a peer declares is read as generously as it was before', () => {
|
||||||
|
test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => {
|
||||||
|
assert.equal(encodingByDataCoding(0x01), 'ASCII');
|
||||||
|
|
||||||
|
const octets = Buffer.from('436f73742035022000686f6d65', 'hex');
|
||||||
|
|
||||||
|
for (const dataCoding of [0x00, 0x01]) {
|
||||||
|
assert.equal(decodeMessage(octets, dataCoding).message, bothTables, String(dataCoding));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves a receipt and an inbound message reading the same under either coding', () => {
|
||||||
|
const receiptId = '01a08779-de97-7caa-9d26-e6d50f5c4888';
|
||||||
|
const body = `id:${receiptId} sub:001 dlvrd:001 submit date:2509091430 done date:2509091431 stat:DELIVRD err:000 text:${bothTables}`;
|
||||||
|
|
||||||
|
for (const dataCoding of [0x00, 0x01]) {
|
||||||
|
const receipt = dlrFromPdu(delivered(body, dataCoding, consts.ESM_CLASS.MC_DELIVERY_RECEIPT));
|
||||||
|
|
||||||
|
assert.ok(receipt, String(dataCoding));
|
||||||
|
assert.equal(receipt.smsId, receiptId, String(dataCoding));
|
||||||
|
assert.equal(receipt.statusMsg, 'DELIVERED', String(dataCoding));
|
||||||
|
|
||||||
|
const inbound = delivered(Buffer.from('436f73742035022000686f6d65', 'hex'), dataCoding, 0);
|
||||||
|
|
||||||
|
assert.equal(dlrFromPdu(inbound), undefined, String(dataCoding));
|
||||||
|
assert.equal(inbound.params.short_message, bothTables, String(dataCoding));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test, { describe } from 'node:test';
|
import test, { describe } from 'node:test';
|
||||||
import { detect, encodingByDataCoding, encodings, unencodable } from '../src/defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts';
|
||||||
|
|
||||||
describe('ASCII (GSM 03.38)', () => {
|
describe('ASCII (GSM 03.38)', () => {
|
||||||
const samples: [string, number[]][] = [
|
const samples: [string, number[]][] = [
|
||||||
@@ -188,6 +188,14 @@ describe('encodingByDataCoding()', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reads every coding dataCodingByEncoding writes back as the alphabet that wrote it', () => {
|
||||||
|
for (const name of Object.keys(dataCodingByEncoding)) {
|
||||||
|
if (!isEncodingName(name)) return assert.fail(`${name} names no alphabet`);
|
||||||
|
|
||||||
|
assert.equal(encodingByDataCoding(dataCodingByEncoding[name]), name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('falls back to ASCII for alphabets it has no codec for', () => {
|
test('falls back to ASCII for alphabets it has no codec for', () => {
|
||||||
assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
||||||
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ describe('sendSms() flash', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
|
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
|
||||||
assert.deepEqual(dataCodingsOf(smsc.octets), [0x01, 0x10, 0x08, 0x18, 0x03]);
|
assert.deepEqual(dataCodingsOf(smsc.octets), [0x00, 0x10, 0x08, 0x18, 0x03]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
|
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ const to = '46709771337';
|
|||||||
const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7);
|
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. */
|
/** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */
|
||||||
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000001000b48656c6c6f20776f726c64';
|
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000000000b48656c6c6f20776f726c64';
|
||||||
|
|
||||||
/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */
|
/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */
|
||||||
const threeSegmentOctets = [
|
const threeSegmentOctets = [
|
||||||
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000001009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
|
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000000009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
|
||||||
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000001009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
|
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000000009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
|
||||||
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000100710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
|
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000000710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
|
||||||
];
|
];
|
||||||
|
|
||||||
type BoundPeer = {
|
type BoundPeer = {
|
||||||
|
|||||||
+3
-2
@@ -130,7 +130,8 @@ describe('parsing real PDUs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('encoding submit_sm', () => {
|
describe('encoding submit_sm', () => {
|
||||||
test('produces the same bytes as 0.4.0 for a GSM message', () => {
|
// 0.4.0 declared IA5 for the GSM octets it wrote; that one field is the whole difference.
|
||||||
|
test('produces the 0.4.0 bytes for a GSM message, under the alphabet those octets are in', () => {
|
||||||
const pdu = encode({
|
const pdu = encode({
|
||||||
cmdName: 'submit_sm',
|
cmdName: 'submit_sm',
|
||||||
cmdStatus: 'ESME_ROK',
|
cmdStatus: 'ESME_ROK',
|
||||||
@@ -144,7 +145,7 @@ describe('encoding submit_sm', () => {
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
pdu.toString('hex'),
|
pdu.toString('hex'),
|
||||||
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000001000b48656c6c6f20776f726c64',
|
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000000000b48656c6c6f20776f726c64',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ describe('an alphabet the caller named that cannot carry the message', () => {
|
|||||||
assert.equal((await session.sendSms({ from, message, to })).err, undefined, message);
|
assert.equal((await session.sendSms({ from, message, to })).err, undefined, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.deepEqual(sentAs(smsc.octets), [[0x01, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
|
assert.deepEqual(sentAs(smsc.octets), [[0x00, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -120,19 +120,6 @@ session message is a change to every call site.
|
|||||||
another message's in a correlation table. The cost is that every consumer narrows, including
|
another message's in a correlation table. The cost is that every consumer narrows, including
|
||||||
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
|
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
|
||||||
**This one is 1.0.0-or-never** — after release it needs a major version.
|
**This one is 1.0.0-or-never** — after release it needs a major version.
|
||||||
- [ ] **A default GSM send declares IA5, not GSM 03.38.** `dataCodingFor()` resolves `encoding`
|
|
||||||
through `consts.ENCODING`, so `'ASCII'` goes out as `data_coding` 0x01 — SMPP 3.4 5.2.19's
|
|
||||||
*IA5 (CCITT T.50)/ASCII* — while the codec writes the GSM 03.38 table, where `@` is 0x00 and
|
|
||||||
`$` is 0x02 against IA5's NUL and STX. `encodingByDataCoding()` reads 0x00 back as GSM 03.38,
|
|
||||||
so this library's writer and reader disagree, and every peer in `interop-tests/` submits GSM
|
|
||||||
text at `data_coding` 0. Nothing asserts what our own default send declares, and the echo
|
|
||||||
tests use characters where the two tables agree, which is why the suite is green.
|
|
||||||
`test/message-class.test.ts` pins the current value. Goal 1 owns it. The fix is a three-entry
|
|
||||||
map in `send-sms.ts` (`ASCII` → 0x00, `LATIN1` → 0x03, `UCS2` → 0x08) rather than any move of
|
|
||||||
a published constant — `consts.ENCODING.ASCII` = 0x01 is a correct name for SMPP's flat-table
|
|
||||||
IA5 entry, and the defect is using that group as the option's vocabulary. **Which value an
|
|
||||||
operator should see is the maintainer's call**, and it wants an `interop-tests/` run. Raised
|
|
||||||
by the architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09.
|
|
||||||
- [ ] Tag `v1.0.0` to publish.
|
- [ ] Tag `v1.0.0` to publish.
|
||||||
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
||||||
something CI should do.
|
something CI should do.
|
||||||
|
|||||||
Reference in New Issue
Block a user