Refuse what we cannot send: an alphabet that cannot carry the message, and a time nobody can read (#97)
* Regression tests for a named alphabet and a time that cannot carry the send * Refuse a message the named alphabet cannot carry, and a time nobody can read * Close the architecture review: a builder that cannot drop a time, and docs that hold * Refuse a time of no known kind, and clamp a relative period to what the format holds * Reflow the decision record and name the clamp in its test
This commit is contained in:
+46
-1
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import { detect, encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
|
||||
import { detect, encodingByDataCoding, encodings, unencodable } from '../src/defs/encodings.ts';
|
||||
|
||||
describe('ASCII (GSM 03.38)', () => {
|
||||
const samples: [string, number[]][] = [
|
||||
@@ -105,6 +105,51 @@ describe('detect()', () => {
|
||||
assert.equal(detect('تست'), 'UCS2');
|
||||
assert.equal(detect('۱۲۳۴۵۶۷۸۹۰'), 'UCS2');
|
||||
});
|
||||
|
||||
// Exhaustive: sendSms() leaves the automatic path unchecked on this and nothing else.
|
||||
test('never picks an alphabet that loses a character, for any code point there is', () => {
|
||||
for (let code = 0; code <= 0x10FFFF; code++) {
|
||||
if (code >= 0xD800 && code <= 0xDFFF) continue;
|
||||
|
||||
const char = String.fromCodePoint(code);
|
||||
const lost = unencodable(char, detect(char));
|
||||
|
||||
if (lost) assert.fail(`detect() picked ${detect(char)} for U+${code.toString(16)}, which loses it`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unencodable()', () => {
|
||||
test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => {
|
||||
assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 });
|
||||
assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 });
|
||||
assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined);
|
||||
assert.equal(unencodable('Åsa', 'ASCII'), undefined);
|
||||
assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined);
|
||||
assert.equal(unencodable('あいう😀', 'UCS2'), undefined);
|
||||
});
|
||||
|
||||
test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => {
|
||||
assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 });
|
||||
assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 });
|
||||
});
|
||||
|
||||
test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => {
|
||||
const every = Buffer.from(Array.from({ length: 256 }, (_, byte) => byte));
|
||||
|
||||
assert.equal(unencodable(every.toString('latin1'), 'LATIN1'), undefined);
|
||||
assert.deepEqual(unencodable('Ā', 'LATIN1'), { char: 'Ā', index: 0 });
|
||||
});
|
||||
|
||||
test('is not match(), which says what detect() may pick rather than what the alphabet holds', () => {
|
||||
assert.equal(encodings.LATIN1.match('Hello world'), false);
|
||||
assert.equal(unencodable('Hello world', 'LATIN1'), undefined);
|
||||
});
|
||||
|
||||
test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => {
|
||||
assert.equal(unencodable('\x1Be', 'ASCII'), undefined);
|
||||
assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodingByDataCoding()', () => {
|
||||
|
||||
+25
-5
@@ -234,25 +234,45 @@ describe('smppDate()', () => {
|
||||
|
||||
describe('smppTime', () => {
|
||||
test('encodes an absolute time', () => {
|
||||
assert.equal(smppTime.encode(new Date(Date.UTC(2026, 7, 25, 14, 30, 0))), '260825143000000+');
|
||||
assert.equal(smppTime.encode(new Date(Date.UTC(2026, 7, 25, 14, 30, 0))).text, '260825143000000+');
|
||||
});
|
||||
|
||||
test('encodes a relative time given in seconds', () => {
|
||||
assert.equal(smppTime.encode(3600), '000000010000000R');
|
||||
test('encodes a relative time given in seconds, clamped to what the format holds', () => {
|
||||
assert.equal(smppTime.encode(3600).text, '000000010000000R');
|
||||
assert.equal(smppTime.encode(99 * 86400).text, '000099000000000R');
|
||||
assert.equal(smppTime.encode(100 * 86400).text, '000099235959000R');
|
||||
});
|
||||
|
||||
test('passes an already-formatted string through', () => {
|
||||
assert.equal(smppTime.encode('260825143000000+'), '260825143000000+');
|
||||
assert.equal(smppTime.encode('260825143000000+').text, '260825143000000+');
|
||||
});
|
||||
|
||||
test('decodes an absolute time back to the same instant', () => {
|
||||
const when = new Date(Date.UTC(2026, 7, 25, 14, 30, 0));
|
||||
const { err, date } = smppTime.decode(smppTime.encode(when));
|
||||
const encoded = smppTime.encode(when);
|
||||
|
||||
assert.ok(encoded.text);
|
||||
|
||||
const { err, date } = smppTime.decode(encoded.text);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.equal(date.toISOString(), when.toISOString());
|
||||
});
|
||||
|
||||
test('reports a time it cannot express rather than encoding it as a string of NaNs', () => {
|
||||
for (const value of [new Date('nope'), NaN, Infinity, -Infinity]) {
|
||||
const encoded = smppTime.encode(value);
|
||||
|
||||
assert.ok(encoded.err instanceof Error, String(value));
|
||||
assert.equal(encoded.text, undefined);
|
||||
}
|
||||
|
||||
const invalidDate = smppTime.encode(new Date('nope'));
|
||||
|
||||
assert.ok(invalidDate.err);
|
||||
assert.match(invalidDate.err.message, /invalid Date/);
|
||||
});
|
||||
|
||||
test('reports malformed input rather than returning an invalid date', () => {
|
||||
assert.ok(smppTime.decode('nonsense').err instanceof Error);
|
||||
assert.ok(smppTime.decode('').err instanceof Error);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import type { PduObjectInput } from '../src/pdu.ts';
|
||||
import type { SendSmsDeps, SendSmsInput } from '../src/send-sms.ts';
|
||||
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||
import { decodeMessage } from '../src/message.ts';
|
||||
import { paramNumber, paramText } from '../src/defs/types.ts';
|
||||
import { pduToObj } from '../src/pdu.ts';
|
||||
import { silentLog } from '../src/log.ts';
|
||||
import { submitSms } from '../src/send-sms.ts';
|
||||
|
||||
const from = '46701113311';
|
||||
const to = '46709771337';
|
||||
|
||||
/** A send that records what it is handed, so a refusal shows up as an empty attempt log. */
|
||||
function recordingDeps(attempts: PduObjectInput[]): SendSmsDeps {
|
||||
return {
|
||||
log: silentLog,
|
||||
reference: 1,
|
||||
send: input => {
|
||||
attempts.push(input);
|
||||
|
||||
return Promise.resolve({ err: new Error('the recording peer never answers') });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The alphabet each submit_sm declared, beside the text those octets read back as. */
|
||||
function sentAs(octets: Buffer[]): [number, string][] {
|
||||
return octets.map(pdu => {
|
||||
const { pduObj } = pduToObj(pdu);
|
||||
|
||||
assert.ok(pduObj);
|
||||
|
||||
const dataCoding = paramNumber(pduObj.params.data_coding, 0);
|
||||
|
||||
return [dataCoding, decodeMessage(pduObj.shortMessageOctets ?? Buffer.alloc(0), dataCoding).message];
|
||||
});
|
||||
}
|
||||
|
||||
describe('an alphabet the caller named that cannot carry the message', () => {
|
||||
// Latin-1 takes the low octet of every code point, so あ (U+3042) went out as 0x42 — the letter B.
|
||||
test('refuses a Latin-1 send of a character above 0xFF, naming the character', async () => {
|
||||
const attempts: PduObjectInput[] = [];
|
||||
const sent = await submitSms(recordingDeps(attempts), { encoding: 'LATIN1', from, message: 'あいう', to });
|
||||
|
||||
assert.ok(sent.err instanceof Error);
|
||||
assert.match(sent.err.message, /LATIN1/);
|
||||
assert.match(sent.err.message, /"あ"/);
|
||||
assert.match(sent.err.message, /U\+3042/);
|
||||
assert.deepEqual(sent.pduObjs, []);
|
||||
assert.deepEqual(sent.smsIds, []);
|
||||
assert.equal(sent.unanswered, 0);
|
||||
assert.equal(attempts.length, 0, 'a message the named alphabet cannot carry puts nothing on the wire');
|
||||
});
|
||||
|
||||
// Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space.
|
||||
test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => {
|
||||
const attempts: PduObjectInput[] = [];
|
||||
const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to });
|
||||
|
||||
assert.ok(sent.err instanceof Error);
|
||||
assert.match(sent.err.message, /ASCII/);
|
||||
assert.match(sent.err.message, /"ï"/);
|
||||
assert.match(sent.err.message, /U\+00EF/);
|
||||
assert.match(sent.err.message, /index 6/);
|
||||
assert.equal(attempts.length, 0);
|
||||
});
|
||||
|
||||
test('never refuses UCS2, which carries every character there is', async () => {
|
||||
const attempts: PduObjectInput[] = [];
|
||||
const deps = recordingDeps(attempts);
|
||||
|
||||
for (const message of ['あいう', 'Åsa naïve', '😀 beyond the basic plane', '�']) {
|
||||
const sent = await submitSms(deps, { encoding: 'UCS2', from, message, to });
|
||||
|
||||
assert.equal(sent.err?.message, 'the recording peer never answers', message);
|
||||
}
|
||||
|
||||
assert.equal(attempts.length, 4);
|
||||
});
|
||||
|
||||
test('sends 8-bit binary named as Latin-1 unchanged, the case that codec is named for', async t => {
|
||||
const messageId = '01a086cc-5ee9-759d-ba4c-96a9afd9aed9';
|
||||
const smsc = await dummySmsc(t, { messageIds: [messageId] });
|
||||
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||
const payload = Buffer.from(Array.from({ length: 140 }, (_, at) => (at * 7 + 3) % 256));
|
||||
const sent = await session.sendSms({ encoding: 'LATIN1', from, message: payload.toString('latin1'), to });
|
||||
const first = smsc.octets[0];
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
assert.deepEqual(sent.smsIds, [messageId]);
|
||||
assert.equal(smsc.octets.length, 1);
|
||||
assert.ok(first);
|
||||
|
||||
const { pduObj } = pduToObj(first);
|
||||
|
||||
assert.ok(pduObj);
|
||||
assert.equal(paramNumber(pduObj.params.data_coding, 0), 0x03);
|
||||
assert.deepEqual(pduObj.shortMessageOctets, payload);
|
||||
});
|
||||
|
||||
test('picks an alphabet that fits where the caller named none, so nothing is ever refused for one', async t => {
|
||||
const smsc = await dummySmsc(t);
|
||||
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||
|
||||
for (const message of ['Hello world', 'Åsa naïve', 'あいう']) {
|
||||
assert.equal((await session.sendSms({ from, message, to })).err, undefined, message);
|
||||
}
|
||||
|
||||
assert.deepEqual(sentAs(smsc.octets), [[0x01, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a time no peer can read', () => {
|
||||
const invalid = new Date('nope');
|
||||
|
||||
test('refuses an invalid Date rather than writing NaN into the PDU, naming the option', async () => {
|
||||
const attempts: PduObjectInput[] = [];
|
||||
const deps = recordingDeps(attempts);
|
||||
const sends: [string, SendSmsInput][] = [
|
||||
['scheduleDeliveryTime', { from, message: 'Hello world', scheduleDeliveryTime: invalid, to }],
|
||||
['validityPeriod', { from, message: 'Hello world', to, validityPeriod: invalid }],
|
||||
];
|
||||
|
||||
for (const [option, send] of sends) {
|
||||
const sent = await submitSms(deps, send);
|
||||
|
||||
assert.ok(sent.err instanceof Error, option);
|
||||
assert.match(sent.err.message, new RegExp(option));
|
||||
assert.match(sent.err.message, /invalid Date/);
|
||||
assert.deepEqual(sent.smsIds, []);
|
||||
}
|
||||
|
||||
assert.equal(attempts.length, 0, 'a time nobody can read puts nothing on the wire');
|
||||
});
|
||||
|
||||
test('refuses a relative period naming no number of seconds', async () => {
|
||||
const attempts: PduObjectInput[] = [];
|
||||
const deps = recordingDeps(attempts);
|
||||
|
||||
for (const validityPeriod of [NaN, Infinity, -Infinity]) {
|
||||
const sent = await submitSms(deps, { from, message: 'Hello world', to, validityPeriod });
|
||||
|
||||
assert.ok(sent.err instanceof Error, String(validityPeriod));
|
||||
assert.match(sent.err.message, /validityPeriod/);
|
||||
}
|
||||
|
||||
assert.equal(attempts.length, 0);
|
||||
});
|
||||
|
||||
test('refuses a time that is no kind of time rather than throwing out of the send', async () => {
|
||||
const attempts: PduObjectInput[] = [];
|
||||
const deps = recordingDeps(attempts);
|
||||
|
||||
for (const validityPeriod of [null, {}, true, []]) {
|
||||
const sent = await submitSms(deps, { from, message: 'Hello world', to, validityPeriod });
|
||||
|
||||
assert.ok(sent.err instanceof Error, JSON.stringify(validityPeriod));
|
||||
assert.match(sent.err.message, /validityPeriod must be a Date/);
|
||||
}
|
||||
|
||||
assert.equal(attempts.length, 0);
|
||||
});
|
||||
|
||||
test('writes the times it can express into every segment it built them for', async t => {
|
||||
const smsc = await dummySmsc(t);
|
||||
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||
const sent = await session.sendSms({
|
||||
from,
|
||||
message: 'A message long enough to need a header numbering its segments. '.repeat(4),
|
||||
scheduleDeliveryTime: new Date(Date.UTC(2026, 8, 9, 14, 30, 0)),
|
||||
to,
|
||||
validityPeriod: 3600,
|
||||
});
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
assert.equal(smsc.octets.length, 2, 'the fixture must need more than one segment');
|
||||
|
||||
for (const octets of smsc.octets) {
|
||||
const { pduObj } = pduToObj(octets);
|
||||
|
||||
assert.ok(pduObj);
|
||||
assert.equal(paramText(pduObj.params.schedule_delivery_time), '260909143000000+');
|
||||
assert.equal(paramText(pduObj.params.validity_period), '000000010000000R');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user