Fixtures for the receipt bodies operator documentation publishes (#93)

* Fixtures for the receipt bodies operator documentation publishes

* Read the stat:FAILED several operators write as UNDELIVERABLE

* Cover the 16-bit UDH concatenation element with a fixture

* Record phase 9 of the interoperability plan

* Share the dummy SMSC and tighten the operator fixtures

* Correct what the test conventions claim about the doubles and the tree

* Read a receipt date that carries its century

* Read the stat spelling CM.com publishes

* Say what the receipt status table actually holds
This commit is contained in:
2026-09-08 18:56:57 +02:00
committed by GitHub
parent 83176f53b0
commit 5a0cf56754
11 changed files with 946 additions and 58 deletions
+44 -3
View File
@@ -198,9 +198,16 @@ exactly 140.
preambles or restate what the code says.
- Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders.
- Fixtures that encode the wire are shared so no two files can drift on it: `test/raw-pdus.ts` builds
the octets a test writes straight to a socket, the PDUs `objToPdu()` refuses to build included. The
waiting helpers each file carries are copies, tolerated because a wrong one fails that file's own
tests and nothing else.
the octets a test writes straight to a socket, the PDUs `objToPdu()` refuses to build included. So
is the peer that answers on its own: `test/dummy-smsc.ts` is the one auto-answering SMSC, because
two copies drift in what they answer rather than in what a test asserts, and one that quietly stops
answering `enquire_link` fails the file that copied it for a reason nothing in that file names.
Reach for it where the peer's answers are not what the test is about; where they are, `smscPeer()`
in `test/session.test.ts` answers the bind and hands every other PDU to the test to answer, and
stays there because that is a different peer rather than a second copy of this one. The waiting
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
footing — it encodes no wire fact `objToPdu()` does not already own.
- `message_id` values the library generates are UUID v7.
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
@@ -427,6 +434,31 @@ Grouped by what each one constrains.
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
absent one cannot land there.
- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the
operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal
delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints
`DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN`
the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an
operator's "it failed" from its "I do not know", nor a delivered message from one whose state
could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send
carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on
writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which
is 3.4's own numbered table — the code has no number there, so one would have to be invented, and
every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it
`UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal
failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the
numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of
their own rather than the seven characters `stat:` holds. Accepted: all three of those operators
document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an
application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote.
Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a
report where it used to arrive as an inbound message, which is what every code already in the table
does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the
page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a
spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely
contain a typo saves an application everything where they do not.
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
@@ -757,3 +789,12 @@ Grouped by what each one constrains.
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
`dist/index.js`, the one published entry. Valid while that map is what a reader navigates by.
- **`test/` stays flat too, and a file there is named for the question it answers rather than for the
module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest
is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where
it came from, cutting across four modules, where filing it by module would enter each new operator
twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of
their own. The four files that are not tests are the exception the rule needs stated:
`dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are
named for what they hold.
+7 -1
View File
@@ -129,6 +129,10 @@ one id per segment:
const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
```
`smsIds` is positional with `pduObjs`, and an entry is empty where the SMSC accepted the segment
without naming an id for it — some name one for the first segment only. No receipt ever matches an
empty entry.
`err` is set when the SMSC refuses a segment, and it names the status it refused with. Because every
segment goes on the wire together, `pduObjs` and `smsIds` then hold what the SMSC did accept — enough
to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a
@@ -158,7 +162,9 @@ to tell the two apart. `esm_class` is what tells them apart; where it names no m
`receipted_message_id` TLV does, and failing both the message body is read for the standard
`id:` and `stat:` receipt fields. That body is read as text whatever `data_coding` the receipt
declares, since SMSCs commonly copy the reported message's onto it. An intermediate delivery
notification is the SMSC reporting as well, not an inbound message.
notification is the SMSC reporting as well, not an inbound message. `stat:FAILED`, which several
operators write and SMPP does not define, reads as `UNDELIVERABLE`; `dlr.receipt.stat` carries the
code the SMSC wrote.
Where the body sits, and which command carried it, changes none of that. An SMSC that leaves
`sm_length` 0 and puts the body in the `message_payload` TLV — SMPP's way of carrying up to 64 KB,
+2 -1
View File
@@ -246,7 +246,8 @@ defect; each is a claim resting on the specification and on Node rather than on
| 6 | done | [06-python-php.md](findings/06-python-php.md) |
| 7 | done | [07-load.md](findings/07-load.md) |
| 8 | dropped | [Untested](#untested) |
| 911 | not started | — |
| 9 | done | [09-operator-fixtures.md](findings/09-operator-fixtures.md) |
| 1011 | not started | — |
Phases 1 to 5 were graded before `run.py` learned to fail an empty capture, so a run whose capture
never started would have scored green on the wire checks while its own assertions carried it. Every
@@ -0,0 +1,208 @@
# 09 operator receipt fixtures
Date: 2026-09-08. Repo commit at the start of the phase: `83176f5`. Host: Alpine 6.18.38-0-virt
kernel. Images: `node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`) — and
nothing else. No peer runs in this phase and no capture is taken.
## Setup
This phase has no peer to bring up, so `run.py` is not involved. The eight peers the suite can run
are all open source, and none of them writes the receipt bodies commercial operators document —
that whole class of behaviour is what
[research/operator-quirks.md](../research/operator-quirks.md) topics 4 and 5 collected, one source
URL per claim, and what this phase turns into fixtures in `test/`.
Everything here runs under the ordinary suite:
```bash
docker compose run --rm node npm test
```
New file: `test/operator-receipts.test.ts` — a table of receipt bodies as each operator's own
documentation spells them, checked through `dlrFromPdu()`, plus four scenarios driven over a live
link against a dummy SMSC that answers each `submit_sm` with the message id the fixture names. Each
fixture carries the URL it was read from as its assertion message, so a failure names the page that
settles it. That dummy SMSC is `test/dummy-smsc.ts`, extracted from the copy `messaging-mode.test.ts`
already carried rather than written a second time. `test/session-extras.test.ts` gained C8's 16-bit
UDH and `test/session.test.ts` the `DlrMerger` fact the Telesign scenario turned up.
## What the research settles, and what it does not
Several things in topic 5 could not be taken at face value, and are recorded rather than guessed at:
- **Telesign's `err` width contradicts itself.** The page calls it "a 3-octet hex code" and then
gives 8-hex-digit examples (`0x000004A6`), which is four octets
(https://developer.telesign.com/enterprise/docs/smpp-protocol). smpp.org fixes the receipt field
at 3 octets. The fixture takes the 3-octet width and the hex notation (`err:4A6`) and asserts the
value reaches `dlr.errorCode` verbatim — this library never parses `err:`, so either reading
arrives intact at the application, which is the only claim the sourced material supports.
- **tyntec does not document the `stat:` its buffered receipt carries**, only that a buffered one
precedes the final one. The fixture uses `stat:ENROUTE` under `esm_class` 0x04 — Appendix B's own
spelling for a message still on its way, and the shape Infobip documents explicitly — rather than
inventing a vendor token.
- **Telesign's `message_parts_count` TLV has no published tag id** in either sourced page, so no
fixture names one. The behaviour it accompanies (only the first segment answered with a
`message_id`) is covered without it.
- **Telesign's `message_state` 9 is not a receipt body shape**, so it is not in the operator table.
Appendix B has no seven-character code for `SKIPPED`, so a fixture pairing the two would have had
Telesign's body and TLV contradict each other where its own page says the status is stated
redundantly in both. The TLV rule is asserted where it belongs instead — `dlr.test.ts` "names a
state only the TLV can spell" — and the Telesign fixture states one status in both fields, as
documented. The research file of 2026-09-05 is the source for 9 = `SKIPPED`; the TLV page no
longer shows that table.
- **Vonage's `stat:` set could not be re-fetched** during review (the support article answers 403).
Kaleyra and Route Mobile both verify independently and both define `FAILED` as a terminal delivery
failure, which is what the library change rests on; Vonage's own developer page documents a
lower-case status set for its HTTP callbacks, which is a different surface from the seven-character
`stat:` field. The fixture keeps the research's attribution, and `src/dlr.ts` cites the two that
verify.
- **Clickatell's cited page no longer resolves**`archive.clickatell.com/developers/api-docs/pdu-details/`
now redirects to `docs.clickatell.com`. The body shape is quoted verbatim in the research file of
2026-09-05, which is what the fixture was built from.
Two further items in topic 5 are not receipt-body shapes at all and are out of this phase:
Syniverse's and Route Mobile's numeric status tables are vendor fields of their own rather than the
seven characters `stat:` holds, and LINK Mobility's `registered_delivery=0x21` is a submit field.
## Scenarios (PLAN.md)
| Id | Result | Evidence |
| --- | --- | --- |
| C16 LINK Mobility: `sub:000`, `dlvrd:000`, empty `text:`, finals only | pass | `operator-receipts.test.ts` "LINK Mobility, whose sub and dlvrd are always 000…" — `receipt.sub`/`receipt.dlvrd` read 0 and nothing is derived from them, `receipt.text` is `''`; "finds none of LINK Mobility's among the transient ones" |
| C16 Vonage: `stat:FAILED` outside Appendix B, eight-value set, `err:` off `DELIVRD`/`ACCEPTD` only | fail, then fixed | "Vonage, whose stat:FAILED is six characters and outside Appendix B" and "names a state of its own for every one of them" — both failed against `83176f5`; see Defects |
| C16 Vonage: one receipt per segment | pass | "reports every segment and merges nothing" — three `dlr` events under the SMSC's own three unrelated ids, no `messageDlr` |
| C16 tyntec: a buffered receipt then a final one for one id | pass | "hands both receipts to the application rather than taking the second for a duplicate" — two `dlr` events, `intermediate` `[true, false]`, one `smsId` |
| C16 Infobip: `stat:ENROUTE` marked `esm_class` 0x04 | pass | "Infobip, reporting ENROUTE in an ordinary receipt that carries no text field at all" — `intermediate` true off the state where the marker says final, and `receipt.text` stays `undefined` |
| C16 Clickatell: the exact documented body | pass | "Clickatell, whose dates carry seconds" — every field of the documented order, 12-octet dates |
| C16 Telesign: hex `err:`, one id per concatenated send | pass | "Telesign, whose err is hexadecimal and whose status is stated in the body and the TLVs alike"; "hands the err field over as it arrived, whichever width the operator writes"; "hands back what landed where only the first segment is answered with one" |
| C16 CM.com: a four-digit year, an eight-character `stat:`, no `sub:` or `dlvrd:` | fail, then fixed | "CM.com, which writes a four-digit year, an eight-character stat, and the status twice" — absent fields stay `undefined` and the `message_state` TLV agrees with `stat:`, but both the documented `yyyyMMddHHmmss` date and the documented `stat:DELIVERD` failed against `83176f5`; see Defects |
| C16 a receipt with no `id:` at all | pass | "settles a status against no message where a marked receipt names no id" — marked, `smsId` is undefined and the status still settles; unmarked, the same body arrives as an `sms`. "takes the id from the TLV where the body names none" covers the third case |
| C16 fields in another order | pass | "reads the same fields whatever order they arrive in"; "reads the rest of the line as the text where a peer does not write text last" |
| C16 hex `message_id` against a decimal `id:` | pass | "correlates the receipt against the send once both notations are named" and "leaves the two incomparable where neither notation is named" |
| C16 a zero-padded id | pass | "strips the padding an operator writes the same number with" |
| C16 dates with and without seconds | pass | The LINK Mobility and Infobip fixtures carry 10-octet dates, Clickatell and Telesign 12-octet ones and CM.com a 14-octet one; every one asserts the `Date` it resolves to, and `dlr.test.ts` "reads a receipt date whichever of the three widths the peer writes it in" pins all three against each other |
| C8 UDH 16-bit | pass | `session-extras.test.ts` "names the spelling a segment was numbered by, alongside the reference" reads GSM 03.40 element 0x08, and "assembles a message numbered by a 16-bit UDH reference" carries two of them through the `Reassembler` into one whole text — the width was previously exercised only by the jsmpp peer run ([05-java-clients.md](05-java-clients.md)) |
| C9 MO or receipt on `data_sm` | pass, already covered | `dlr.test.ts` "reads a receipt the peer carried in message_payload, on deliver_sm and on data_sm"; `session-extras.test.ts` "reads a data_sm as the command its direction makes it" |
| C10 unknown command id, malformed and vendor TLVs | pass, already covered | `test/raw-pdus.ts` and the `session.test.ts` refusal suites |
| C15 `interfaceVersion` 0x50, a peer answering 3.3 or nothing | pass, already covered | `session.test.ts` bind-version suites around `sc_interface_version` |
## Defects in @larvit/smpp
### `stat:FAILED` read as `UNKNOWN`
**What happened.** A receipt body carrying `stat:FAILED` reached the application as
`statusMsg: 'UNKNOWN'`, `statusId: 7` — indistinguishable from a receipt that really says
`stat:UNKNOWN`.
**What the operators' docs say.** Vonage lists `FAILED` among the eight `stat` values it writes
(https://api.support.vonage.com/hc/en-us/articles/204015663), Kaleyra among its four
(https://messaging.kaleyra.com/support/solutions/articles/3000091798-delivery-reports), and Route
Mobile among its five (https://routemobile.com/pdf_files/developer/api/routemobilesmpp.pdf). In all
three it is a terminal delivery failure. SMPP 3.4 Appendix B does not define it, and it is six
characters where the field is seven.
**Reproducer.** `operator-receipts.test.ts`, the Vonage fixture and "names a state of its own for
every one of them" — the second walks every code all seven researched operators publish and fails
on any that reads as `UNKNOWN` without saying `UNKNOWN`.
**Severity.** Two ways it gives a wrong answer, both goal 2: an application cannot tell an
operator's "it failed" from its "I do not know", and `DlrMerger` ranks `UNKNOWN` (5) below `EXPIRED`
(6), so a multipart send with one failed segment and one expired one reported as expired.
**Fixed** in this phase: one entry added to `receiptStates` in `src/dlr.ts`. `receiptCodes` is
untouched, so this library still only ever writes `UNDELIV`. Decision recorded in the root
`AGENTS.md` under "The wire".
### CM.com's own `stat:` spelling read as `UNKNOWN`
**What happened.** A receipt spelled the way CM.com's code table prints it reached the application as
`statusMsg: 'UNKNOWN'`. Unmarked, it arrived as an inbound `sms` rather than as a report at all.
**What the operator's docs say.** The "Message state values" table at
https://developers.cm.com/messaging/docs/smpp gives the code column as `DELIVERD` — eight
characters — beside `EXPIRED`, `DELETED`, `UNDELIV`, `ACCEPTD`, `UNKNOWN` and `REJECTD`, which are
all correct Appendix B codes. The page prints `DELIVERD` four times and `DELIVRD` not once, verified
by fetching it.
**Reproducer.** `operator-receipts.test.ts` "names a state of its own for every one of them", whose
CM.com row now carries the published spelling, and the CM.com fixture.
**Severity.** The same class as the `stat:FAILED` defect above, on the most common status there is: an
application could not tell a delivered message from one whose state the library could not read.
**Fixed** in this phase: one entry in `receiptStates`. Whether CM.com's table is a typo or its wire
spelling, reading it costs nothing — no other code could be meant, and `receiptCodes` still writes
only `DELIVRD`. This supersedes the research file's CM.com line, which records the code as `DELIVRD`
and asks for an assertion that every `stat:` is exactly seven characters: written today, that
assertion fails against the page it cites.
### A receipt date carrying its century dropped
**What happened.** `dlr.doneDate` and every other parsed date came back `undefined` for a receipt
whose dates are 14 digits, while `dlr.receipt.doneDate` still carried the raw string — so the loss
was silent.
**What the operator's docs say.** CM.com gives its receipt body template as
`id:… submit date:yyyyMMddHHmmss done date:yyyyMMddHHmmss stat:SSSSSSS err:EEE`, with "Formatted:
yyyyMMddHHmmss" spelled out (https://developers.cm.com/messaging/docs/smpp). `receiptDate()` read 10
and 12 digits only — smpp.org's `YYMMDDhhmm` and the same with seconds.
**Reproducer.** `dlr.test.ts` "reads a receipt date whichever of the three widths the peer writes it
in", and the CM.com row of the operator table.
**Severity.** Goal 3: a date the receipt states plainly is one the library can determine, and
dropping it leaves the application to re-parse `dlr.receipt.doneDate` itself. The three widths are
10, 12 and 14, so none can be read as another and nothing is guessed.
**Fixed** in this phase: `receiptDate()` in `src/dlr.ts` takes a four-digit year as the year, where a
two-digit one still means this century, and the rolled-over check now covers the year as well —
`Date.UTC` reads 26 as 1926.
## Peer quirks
Not peer behaviour this time — operator behaviour, from documentation rather than from a run. What
the fixtures pin that a reader would not otherwise expect:
- **`sub:` and `dlvrd:` say nothing.** LINK Mobility hardcodes both to `000` on every receipt,
delivered ones included, and CM.com omits them entirely. Nothing in this library derives anything
from either, which is what makes both readable.
- **`text:` can only end where the line does**, because it is the one field allowed to hold spaces.
Every researched operator writes it last, and one that did not would have the rest of its line
read as the text. The other seven fields are order-independent.
- **A receipt's `esm_class` and its `stat:` can disagree about finality.** Infobip writes
`stat:ENROUTE` under 0x04, the marker for a final receipt; the state wins, which is why the
library tests both.
- **An operator that hands out an unrelated id per segment gets no `messageDlr`.** Vonage sends one
receipt per segment under ids that carry no `<base>-<n>` numbering, so nothing merges them. An
application wanting one report per message compares each `dlr.smsId` against the `smsIds` array
`sendSms()` returned and merges them itself.
- **Telesign answers only the first segment of a concatenated submit with a `message_id`.**
`sendSms()` returns `['<id>', '', '']` — one entry per segment, positional with `pduObjs`, empty
where the SMSC named nothing. `dlr.smsId` is never empty, so an empty entry matches no receipt,
and no merge is armed.
## Open questions
- **Whether LINK Mobility really writes a space after the colon.** Its guide prints the extended
format as `id: xxx sub:000 dlvrd:000 submit date: yyMMddHHmm ... stat: <status> err: <error code>
text:` — spaced before every placeholder and unspaced before both literal `000`s, which reads as a
typographic convention for placeholders rather than as the wire shape. The fixture takes the
unspaced form every other operator documents. It matters because the parser reads a field as
ending at the first space, so a genuinely spaced receipt yields every field empty, and an unmarked
one would arrive as an inbound message. Tolerating a space cannot simply be added: `sub: stat:UNDELIV`
would then read `stat:UNDELIV` as the value of `sub`, which is the same ambiguity the other way
round. A single receipt off a real LINK link settles it; until then the shape is not guessed at.
- Whether Telesign's `err:` is really three hex characters or eight. Both readings reach the
application unchanged — "hands the err field over as it arrived, whichever width the operator
writes" pins that — so nothing in this library turns on it, but a real Telesign link would settle
it in one receipt.
- What `stat:` tyntec's buffered receipt actually carries. The library reads any of `ENROUTE`,
`SCHEDULED` or `esm_class` 0x20 as non-final, so all three plausible answers behave correctly; a
fourth, vendor-invented token would read as `UNKNOWN` and final.
- Whether any operator writes a `stat:` outside Appendix B beyond the two this phase found,
`FAILED` and CM.com's `DELIVERD`. The documented-codes table in `operator-receipts.test.ts` is
the place a new one goes, and it fails loudly for anything nothing names.
- Whether `smsIds` carrying an empty entry for a segment the SMSC took but named no id for is the
right shape for a caller, or whether that case wants saying differently. It is documented in
`README.md` and pinned by the Telesign scenario; the question is a product one, not a correctness
one, and belongs to the phase 11 product-owner pass.
+16 -8
View File
@@ -10,15 +10,19 @@ import { paramNumber, paramText } from './defs/types.ts';
import { udhLength } from './udh.ts';
/**
* The seven-character status codes carried in a receipt's `stat:` field, mapped to the
* message_state values they correspond to.
* The status codes carried in a receipt's `stat:` field, mapped to the message_state values they
* correspond to. Two of them are not the seven characters the field is meant to hold.
*/
const receiptStates: Record<string, MessageState> = {
ACCEPTD: 'ACCEPTED',
DELETED: 'DELETED',
// CM.com publishes this eight-character spelling of DELIVRD in its own code table.
DELIVERD: 'DELIVERED',
DELIVRD: 'DELIVERED',
ENROUTE: 'ENROUTE',
EXPIRED: 'EXPIRED',
// Kaleyra and Route Mobile document it as a terminal failure; Appendix B does not define it.
FAILED: 'UNDELIVERABLE',
REJECTD: 'REJECTED',
UNDELIV: 'UNDELIVERABLE',
UNKNOWN: 'UNKNOWN',
@@ -82,18 +86,21 @@ function toNumber(value: string | undefined): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
/** Delivery receipt dates are YYMMDDhhmm, sometimes with seconds. */
/** Delivery receipt dates are YYMMDDhhmm, sometimes with seconds, and CM.com states the century. */
function receiptDate(value: string | undefined): Date | undefined {
if (value === undefined) return undefined;
const match = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)?$/.exec(value);
// Ten, twelve and fourteen digits, so no width can be read as another.
const match = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)$/.exec(value)
?? /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)?$/.exec(value);
if (!match) return undefined;
const [, years, months, days, hours, minutes, seconds] = match;
const century = Math.floor(new Date().getUTCFullYear() / 100) * 100;
const century = years?.length === 4 ? 0 : Math.floor(new Date().getUTCFullYear() / 100) * 100;
const year = century + Number(years);
const date = new Date(Date.UTC(
century + Number(years),
year,
Number(months) - 1,
Number(days),
Number(hours),
@@ -101,8 +108,9 @@ function receiptDate(value: string | undefined): Date | undefined {
Number(seconds ?? 0),
));
// Date.UTC rolls 31 February over into March rather than refusing it.
const rolled = date.getUTCMonth() !== Number(months) - 1
// Date.UTC rolls 31 February into March rather than refusing it, and reads year 26 as 1926.
const rolled = date.getUTCFullYear() !== year
|| date.getUTCMonth() !== Number(months) - 1
|| date.getUTCDate() !== Number(days)
|| date.getUTCHours() !== Number(hours)
|| date.getUTCMinutes() !== Number(minutes)
+24
View File
@@ -151,6 +151,30 @@ describe('dlrFromPdu()', () => {
}
});
// CM.com documents its receipt dates as yyyyMMddHHmmss; 10, 12 and 14 are three distinct widths.
test('reads a receipt date whichever of the three widths the peer writes it in', () => {
const at = (date: string): string | undefined =>
dlrFromPdu(deliverSm(`id:x stat:DELIVRD done date:${date}`))?.doneDate?.toISOString();
assert.equal(at('2508251431'), '2025-08-25T14:31:00.000Z');
assert.equal(at('250825143145'), '2025-08-25T14:31:45.000Z');
assert.equal(at('20250825143145'), '2025-08-25T14:31:45.000Z');
assert.equal(at('202508251431'), undefined, 'twelve digits is YYMMDDhhmmss, not a year and no seconds');
assert.equal(at('00250825143145'), undefined, 'Date.UTC would read year 25 as 1925');
});
// message_state 9 is Telesign's SKIPPED, which the seven-character stat field has no code for.
test('names a state only the TLV can spell', () => {
const dlr = dlrFromPdu(deliverSm('id:x stat:UNKNOWN err:000 text:', {
message_state: { tagValue: consts.MESSAGE_STATE.SKIPPED },
}));
assert.ok(dlr);
assert.equal(dlr.statusMsg, 'SKIPPED');
assert.equal(dlr.statusId, 9);
assert.equal(dlr.intermediate, false);
});
test('leaves an impossible receipt date undefined rather than rolling it over', () => {
const rolled = dlrFromPdu(deliverSm('id:x stat:DELIVRD done date:9902310000'));
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import type { Session } from '../src/session.ts';
import type { TestContext } from 'node:test';
import { PduFramer } from '../src/pdu-framer.ts';
import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts';
import { objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
import { uuidv7 } from '../src/uuid.ts';
export type DummySmsc = {
/** Writes a delivery receipt to the ESME, its body spelled as the test names it. */
deliver: (body: string) => void;
/** Every submit_sm the ESME wrote, exactly as it arrived on the socket. */
octets: Buffer[];
port: number;
};
export type DummySmscOptions = {
/** The id each submit is answered with, in order; a spent list answers with none, as Telesign does. */
messageIds?: readonly string[];
};
/**
* An SMSC that answers every request the ESME sends and starts nothing of its own. It exists for the
* peers `server()` cannot be one whose message ids the test chooses, or one that answers a request
* differently from how this library would. A test that answers the peer's requests itself wants
* `smscPeer()` in `session.test.ts` instead, which answers the bind and hands over the rest.
*/
export async function dummySmsc(t: TestContext, options: DummySmscOptions = {}): Promise<DummySmsc> {
const octets: Buffer[] = [];
const sockets: net.Socket[] = [];
let answered = 0;
let delivered = 0;
const nextId = (): string => (options.messageIds ? options.messageIds[answered++] ?? '' : uuidv7());
const listener = net.createServer(sock => {
const framer = new PduFramer();
sockets.push(sock);
sock.on('data', chunk => {
framer.push(chunk);
for (const pdu of framer.next().pdus ?? []) {
const { pduObj } = pduToObj(pdu);
// A response answers nothing; the ESME's deliver_sm_resp is the one that arrives here.
if (!pduObj || pduObj.cmdName.endsWith('_resp')) continue;
// Only a submit takes an id from the list; a bind answered off it shifts every fixture.
const submitted = pduObj.cmdName === 'submit_sm';
const answer = submitted
? pduReturn(pduObj, 'ESME_ROK', { message_id: nextId() })
: pduReturn(pduObj, 'ESME_ROK', { system_id: 'dummy' });
if (submitted) octets.push(pdu);
// Writing nothing leaves the test waiting out its own timeout with nothing naming why.
assert.ok(answer.buffer, `the dummy SMSC has no answer for ${pduObj.cmdName}`);
sock.write(answer.buffer);
}
});
});
closeListenerAfter(t, listener, sockets);
await new Promise<void>(resolve => { listener.listen(0, resolve); });
const address = listener.address();
return {
deliver: (body: string) => {
const { buffer } = objToPdu({
cmdName: 'deliver_sm',
params: {
destination_addr: '46701113311',
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: body,
source_addr: '46709771337',
},
seqNr: ++delivered,
});
assert.ok(buffer);
sockets[sockets.length - 1]?.write(buffer);
},
octets,
port: typeof address === 'object' && address !== null ? address.port : 0,
};
}
/** A client bound to one of the above, torn down with the test. */
export async function bindToSmsc(
t: TestContext,
port: number,
options: Parameters<typeof client>[0] = {},
): Promise<Session> {
const { err, session } = await client({ ...options, port });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
return session;
}
+5 -43
View File
@@ -1,20 +1,16 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test, { describe } from 'node:test';
import type { PduObjectInput } from '../src/pdu.ts';
import type { SendSmsDeps } from '../src/send-sms.ts';
import type { Session } from '../src/session.ts';
import type { SubmitMessagingMode } from '../src/defs/constants.ts';
import type { TestContext } from 'node:test';
import { PduFramer } from '../src/pdu-framer.ts';
import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { consts, submitMessagingModes } from '../src/defs/constants.ts';
import { paramNumber } from '../src/defs/types.ts';
import { pduReturn, pduToObj } from '../src/pdu.ts';
import { pduToObj } from '../src/pdu.ts';
import { silentLog } from '../src/log.ts';
import { submitSms } from '../src/send-sms.ts';
import { uuidv7 } from '../src/uuid.ts';
const from = '46701113311';
const to = '46709771337';
@@ -38,45 +34,11 @@ type BoundPeer = {
session: Session;
};
/** An SMSC that answers a bind and every submit, keeping the octets each submit arrived as. */
async function boundToPeer(t: TestContext): Promise<BoundPeer> {
const octets: Buffer[] = [];
const sockets: net.Socket[] = [];
const listener = net.createServer(sock => {
const framer = new PduFramer();
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
sockets.push(sock);
sock.on('data', chunk => {
framer.push(chunk);
for (const pdu of framer.next().pdus ?? []) {
const { pduObj } = pduToObj(pdu);
if (!pduObj) continue;
if (pduObj.cmdName === 'submit_sm') octets.push(pdu);
const answered = pduReturn(pduObj, 'ESME_ROK', pduObj.cmdName === 'submit_sm'
? { message_id: uuidv7() }
: { system_id: 'byte-peer' });
if (answered.buffer) sock.write(answered.buffer);
}
});
});
closeListenerAfter(t, listener, sockets);
await new Promise<void>(resolve => { listener.listen(0, resolve); });
const address = listener.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
const { err, session } = await client({ port, reconnect: false });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
return { octets, session };
return { octets: smsc.octets, session };
}
function hexOf(octets: Buffer[]): string[] {
+494
View File
@@ -0,0 +1,494 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { Dlr, Receipt } from '../src/dlr.ts';
import type { MessageDlr } from '../src/session.ts';
import type { PduObject, TlvInput } from '../src/pdu.ts';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { consts } from '../src/defs/constants.ts';
import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
/**
* Receipt bodies as commercial operators document them, from `interop-tests/research/operator-quirks.md`
* topics 4 and 5. Every peer the interop suite can run is open source; these shapes are the ones only
* an operator writes, so each fixture carries the URL it was read from.
*/
function deliverSm(
body: string,
tlvs?: Record<string, TlvInput>,
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
): PduObject {
const { buffer } = objToPdu({
cmdName: 'deliver_sm',
params: {
destination_addr: '46701113311',
esm_class: esmClass,
short_message: body,
source_addr: '46709771337',
},
seqNr: 1,
tlvs,
});
assert.ok(buffer);
const { pduObj } = pduToObj(buffer);
assert.ok(pduObj);
return pduObj;
}
type ReceiptFixture = {
body: string;
dlr: {
doneDate: string | undefined;
errorCode: string | undefined;
intermediate: boolean;
smsId: string | undefined;
statusId: number;
statusMsg: Dlr['statusMsg'];
};
name: string;
receipt: Receipt;
source: string;
tlvs?: Record<string, TlvInput>;
};
const fixtures: readonly ReceiptFixture[] = [
{
body: 'id:e731049e9fc84e61 sub:000 dlvrd:000 submit date:2609051430 done date:2609051431 stat:DELIVRD err:000 text:',
dlr: {
doneDate: '2026-09-05T14:31:00.000Z',
errorCode: '000',
intermediate: false,
smsId: 'e731049e9fc84e61',
statusId: consts.MESSAGE_STATE.DELIVERED,
statusMsg: 'DELIVERED',
},
name: 'LINK Mobility, whose sub and dlvrd are always 000 and whose text is always empty',
receipt: {
dlvrd: 0,
doneDate: '2609051431',
err: '000',
id: 'e731049e9fc84e61',
stat: 'DELIVRD',
sub: 0,
submitDate: '2609051430',
text: '',
},
source: 'https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf',
},
{
body: 'id:2a1f0f1d sub:001 dlvrd:000 submit date:2609051430 done date:2609051447 stat:FAILED err:051 text:none',
dlr: {
doneDate: '2026-09-05T14:47:00.000Z',
errorCode: '051',
intermediate: false,
smsId: '2a1f0f1d',
statusId: consts.MESSAGE_STATE.UNDELIVERABLE,
statusMsg: 'UNDELIVERABLE',
},
name: 'Vonage, whose stat:FAILED is six characters and outside Appendix B',
receipt: {
dlvrd: 0,
doneDate: '2609051447',
err: '051',
id: '2a1f0f1d',
stat: 'FAILED',
sub: 1,
submitDate: '2609051430',
text: 'none',
},
source: 'https://api.support.vonage.com/hc/en-us/articles/204015663',
},
{
body: 'id:44191696 sub:001 dlvrd:000 submit date:2609051430 done date:2609051430 stat:ENROUTE err:000',
dlr: {
doneDate: '2026-09-05T14:30:00.000Z',
errorCode: '000',
intermediate: true,
smsId: '44191696',
statusId: consts.MESSAGE_STATE.ENROUTE,
statusMsg: 'ENROUTE',
},
name: 'Infobip, reporting ENROUTE in an ordinary receipt that carries no text field at all',
receipt: {
dlvrd: 0,
doneDate: '2609051430',
err: '000',
id: '44191696',
stat: 'ENROUTE',
sub: 1,
submitDate: '2609051430',
text: undefined,
},
source: 'https://www.infobip.com/docs/essentials/api-essentials/smpp-specification',
},
{
body: 'id:7d94e772 sub:001 dlvrd:001 submit date:260905143012 done date:260905143145 stat:DELIVRD err:000 text:',
dlr: {
doneDate: '2026-09-05T14:31:45.000Z',
errorCode: '000',
intermediate: false,
smsId: '7d94e772',
statusId: consts.MESSAGE_STATE.DELIVERED,
statusMsg: 'DELIVERED',
},
name: 'Clickatell, whose dates carry seconds',
receipt: {
dlvrd: 1,
doneDate: '260905143145',
err: '000',
id: '7d94e772',
stat: 'DELIVRD',
sub: 1,
submitDate: '260905143012',
text: '',
},
source: 'https://archive.clickatell.com/developers/api-docs/pdu-details/',
},
{
body: 'id:5be9f816f19992a78c8e26442f8afa50 submit date:20260905143012 done date:20260905143345 stat:DELIVERD err:000',
dlr: {
doneDate: '2026-09-05T14:33:45.000Z',
errorCode: '000',
intermediate: false,
smsId: '5be9f816f19992a78c8e26442f8afa50',
statusId: consts.MESSAGE_STATE.DELIVERED,
statusMsg: 'DELIVERED',
},
name: 'CM.com, which writes a four-digit year, an eight-character stat, and the status twice',
receipt: {
dlvrd: undefined,
doneDate: '20260905143345',
err: '000',
id: '5be9f816f19992a78c8e26442f8afa50',
stat: 'DELIVERD',
sub: undefined,
submitDate: '20260905143012',
text: undefined,
},
source: 'https://developers.cm.com/messaging/docs/smpp',
tlvs: { message_state: { tagValue: consts.MESSAGE_STATE.DELIVERED } },
},
{
body: 'id:e9ca671b2497d778d771938333dc0c52 sub:001 dlvrd:000 submit date:260905143000 done date:260905143010 stat:UNDELIV err:4A6 text:',
dlr: {
doneDate: '2026-09-05T14:30:10.000Z',
errorCode: '4A6',
intermediate: false,
smsId: 'e9ca671b2497d778d771938333dc0c52',
statusId: consts.MESSAGE_STATE.UNDELIVERABLE,
statusMsg: 'UNDELIVERABLE',
},
name: 'Telesign, whose err is hexadecimal and whose status is stated in the body and the TLVs alike',
receipt: {
dlvrd: 0,
doneDate: '260905143010',
err: '4A6',
id: 'e9ca671b2497d778d771938333dc0c52',
stat: 'UNDELIV',
sub: 1,
submitDate: '260905143000',
text: '',
},
source: 'https://developer.telesign.com/enterprise/docs/smpp-protocol',
tlvs: {
message_state: { tagValue: consts.MESSAGE_STATE.UNDELIVERABLE },
receipted_message_id: { tagValue: 'e9ca671b2497d778d771938333dc0c52' },
},
},
];
describe('receipt bodies operators document', () => {
for (const fixture of fixtures) {
test(fixture.name, () => {
const dlr = dlrFromPdu(deliverSm(fixture.body, fixture.tlvs));
assert.ok(dlr, fixture.source);
assert.deepEqual(dlr.receipt, fixture.receipt, fixture.source);
assert.deepEqual({
doneDate: dlr.doneDate?.toISOString(),
errorCode: dlr.errorCode,
intermediate: dlr.intermediate,
smsId: dlr.smsId,
statusId: dlr.statusId,
statusMsg: dlr.statusMsg,
}, fixture.dlr, fixture.source);
});
}
});
/** Every `stat:` code the researched operators list, per operator, with the page it is on. */
const documentedCodes: readonly { codes: readonly string[]; operator: string; source: string }[] = [
{
codes: ['ACCEPTD', 'DELIVRD', 'REJECTD', 'UNDELIV'],
operator: 'Clickatell',
source: 'https://archive.clickatell.com/developers/api-docs/pdu-details/',
},
{
codes: ['ACCEPTD', 'DELETED', 'DELIVERD', 'EXPIRED', 'REJECTD', 'UNDELIV', 'UNKNOWN'],
operator: 'CM.com',
source: 'https://developers.cm.com/messaging/docs/smpp',
},
{
codes: ['ACCEPTD', 'DELIVRD', 'ENROUTE', 'EXPIRED', 'REJECTD', 'UNDELIV', 'UNKNOWN'],
operator: 'Infobip',
source: 'https://www.infobip.com/docs/essentials/api-essentials/smpp-specification',
},
{
codes: ['DELIVRD', 'EXPIRED', 'FAILED', 'UNDELIV'],
operator: 'Kaleyra',
source: 'https://messaging.kaleyra.com/support/solutions/articles/3000091798-delivery-reports',
},
{
codes: ['DELETED', 'DELIVRD', 'EXPIRED', 'REJECTD', 'UNDELIV'],
operator: 'LINK Mobility',
source: 'https://www.linkmobility.com/resources/developer/SMSC-SMPP-User-Guide-1.5.pdf',
},
{
codes: ['DELIVRD', 'EXPIRED', 'FAILED', 'REJECTD', 'UNDELIV'],
operator: 'Route Mobile',
source: 'https://routemobile.com/pdf_files/developer/api/routemobilesmpp.pdf',
},
{
codes: ['ACCEPTD', 'DELETED', 'DELIVRD', 'EXPIRED', 'FAILED', 'REJECTD', 'UNDELIV', 'UNKNOWN'],
operator: 'Vonage',
source: 'https://api.support.vonage.com/hc/en-us/articles/204015663',
},
];
describe('the status codes operators publish', () => {
test('names a state of its own for every one of them', () => {
for (const { codes, operator, source } of documentedCodes) {
for (const code of codes) {
const dlr = dlrFromPdu(deliverSm(`id:ec421e62 stat:${code} err:000 text:`));
assert.ok(dlr);
assert.equal(
dlr.statusMsg === 'UNKNOWN',
code === 'UNKNOWN',
`${operator} documents stat:${code}, read as ${dlr.statusMsg}${source}`,
);
assert.equal(dlr.intermediate, code === 'ENROUTE', `${operator} stat:${code}${source}`);
}
}
});
// A code the reader cannot name leaves an unmarked deliver_sm arriving as an inbound message.
test('reads an unmarked deliver_sm reporting one of them as a report, not as a message', () => {
for (const code of ['DELIVERD', 'FAILED']) {
const dlr = dlrFromPdu(deliverSm(`id:2a1f0f1d stat:${code} err:051 text:none`, undefined, 0));
assert.ok(dlr, `stat:${code} marks a report even where esm_class does not`);
assert.equal(dlr.smsId, '2a1f0f1d');
}
});
// "Only none or final delivery ... are supported" — the SMSC-SMPP User Guide 1.5, sourced below.
test('finds none of LINK Mobility\'s among the transient ones', () => {
const link = documentedCodes.find(one => one.operator === 'LINK Mobility');
const transient = transientStates.map(state => receiptCodes[state]);
assert.ok(link);
assert.deepEqual(link.codes.filter(code => transient.includes(code)), [], link.source);
});
});
describe('a receipt body that is not the shape the spec fixes', () => {
const id = 'f5c98a862c8d6014';
const ordered = `id:${id} sub:001 dlvrd:001 submit date:2609051430 done date:2609051431 stat:DELIVRD err:000 text:`;
test('reads the same fields whatever order they arrive in', () => {
const shuffled = `stat:DELIVRD err:000 done date:2609051431 dlvrd:001 submit date:2609051430 sub:001 id:${id} text:`;
assert.deepEqual(parseReceipt(shuffled), parseReceipt(ordered));
});
// text: is the one field that may hold spaces, so it can only end where the line does.
test('reads the rest of the line as the text where a peer does not write text last', () => {
const early = `id:${id} text: stat:DELIVRD err:000`;
assert.equal(parseReceipt(early).text, ' stat:DELIVRD err:000');
assert.equal(parseReceipt(early).stat, 'DELIVRD', 'the other fields are still read');
});
test('settles a status against no message where a marked receipt names no id', () => {
const bodyless = 'sub:001 dlvrd:001 submit date:2609051430 done date:2609051431 stat:DELIVRD err:000 text:';
const marked = dlrFromPdu(deliverSm(bodyless));
assert.ok(marked);
assert.equal(marked.smsId, undefined);
assert.equal(marked.statusMsg, 'DELIVERED');
assert.equal(marked.receipt?.id, undefined);
assert.equal(dlrFromPdu(deliverSm(bodyless, undefined, 0)), undefined, 'unmarked, it is a message');
});
// Telesign's page calls err a 3-octet hex code and then gives eight-digit examples of it.
test('hands the err field over as it arrived, whichever width the operator writes', () => {
for (const err of ['4A6', '000004A6']) {
assert.equal(dlrFromPdu(deliverSm(`id:x stat:UNDELIV err:${err}`))?.errorCode, err);
}
});
test('takes the id from the TLV where the body names none', () => {
const id54 = '08472259999bf99e679376b52ebbb685';
const dlr = dlrFromPdu(deliverSm('sub:001 stat:DELIVRD err:000 text:', {
receipted_message_id: { tagValue: id54 },
}, 0));
assert.ok(dlr);
assert.equal(dlr.smsId, id54);
assert.equal(dlr.statusMsg, 'DELIVERED');
});
});
/** Resolves once `count` of them have arrived, so a run short of that fails rather than hangs. */
function collect<T>(count: number, register: (push: (value: T) => void) => void): Promise<T[]> {
const values: T[] = [];
return new Promise<T[]>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`waited 5000 ms for ${String(count)} events, ${String(values.length)} arrived`));
}, 5000);
register(value => {
values.push(value);
if (values.length < count) return;
clearTimeout(timer);
resolve(values);
});
});
}
const message = { from: '46701113311', to: '46709771337' };
function receiptBody(id: string): string {
return `id:${id} sub:001 dlvrd:001 submit date:2609051430 done date:2609051431 stat:DELIVRD err:000 text:`;
}
describe('an SMSC that writes its message ids in two notations', () => {
// Vonage answers a submit in hex and writes the receipt's id: in decimal off the same number.
const hex = '33647f6c';
const decimal = '862224236';
test('correlates the receipt against the send once both notations are named', async t => {
const smsc = await dummySmsc(t, { messageIds: [hex] });
const session = await bindToSmsc(t, smsc.port, {
smsIdFormat: { receipt: 'decimal', submitResp: 'hex' },
});
const reported = collect<Dlr>(1, push => { session.on('dlr', push); });
const sent = await session.sendSms({ dlr: true, message: 'operator receipt', ...message });
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, [decimal]);
smsc.deliver(receiptBody(decimal));
const [dlr] = await reported;
assert.ok(dlr);
assert.equal(dlr.smsId, sent.smsIds[0]);
assert.equal(dlr.receipt?.id, decimal, 'the receipt itself keeps what the operator wrote');
});
test('leaves the two incomparable where neither notation is named', async t => {
const smsc = await dummySmsc(t, { messageIds: [hex] });
const session = await bindToSmsc(t, smsc.port);
const reported = collect<Dlr>(1, push => { session.on('dlr', push); });
const sent = await session.sendSms({ dlr: true, message: 'operator receipt', ...message });
assert.deepEqual(sent.smsIds, [hex]);
smsc.deliver(receiptBody(decimal));
const [dlr] = await reported;
assert.ok(dlr);
assert.equal(dlr.smsId, decimal);
assert.notEqual(dlr.smsId, sent.smsIds[0]);
});
test('strips the padding an operator writes the same number with', async t => {
const smsc = await dummySmsc(t, { messageIds: ['706678557'] });
const session = await bindToSmsc(t, smsc.port, {
smsIdFormat: { receipt: 'decimal', submitResp: 'decimal' },
});
const reported = collect<Dlr>(1, push => { session.on('dlr', push); });
const sent = await session.sendSms({ dlr: true, message: 'operator receipt', ...message });
assert.deepEqual(sent.smsIds, ['706678557']);
smsc.deliver(receiptBody('0000706678557'));
const [dlr] = await reported;
assert.ok(dlr);
assert.equal(dlr.smsId, sent.smsIds[0]);
});
});
describe('an SMSC that reports one message more than once', () => {
// tyntec sends a buffered receipt shortly after submission and a final one later.
test('hands both receipts to the application rather than taking the second for a duplicate', async t => {
const id = 'd91518bd27c1018d';
const smsc = await dummySmsc(t, { messageIds: [id] });
const session = await bindToSmsc(t, smsc.port);
const reported = collect<Dlr>(2, push => { session.on('dlr', push); });
const sent = await session.sendSms({ dlr: true, message: 'buffered then delivered', ...message });
assert.deepEqual(sent.smsIds, [id]);
smsc.deliver(`id:${id} sub:001 dlvrd:000 submit date:2609051430 done date:2609051430 stat:ENROUTE err:000 text:`);
smsc.deliver(receiptBody(id));
const [buffered, final] = await reported;
assert.ok(buffered);
assert.ok(final);
assert.equal(buffered.smsId, id);
assert.equal(final.smsId, id);
assert.deepEqual([buffered.intermediate, final.intermediate], [true, false]);
assert.deepEqual([buffered.statusMsg, final.statusMsg], ['ENROUTE', 'DELIVERED']);
});
});
describe('an SMSC that reports each segment under an id of its own', () => {
// Vonage sends one receipt per segment, and its ids carry no <base>-<n> to merge them by.
test('reports every segment and merges nothing', async t => {
const ids = ['bf53ad8b', '40ccdce2', 'b64bf122'];
const smsc = await dummySmsc(t, { messageIds: ids });
const session = await bindToSmsc(t, smsc.port);
const merged: MessageDlr[] = [];
const reported = collect<Dlr>(3, push => { session.on('dlr', push); });
session.on('messageDlr', report => merged.push(report));
const sent = await session.sendSms({ dlr: true, message: 'x'.repeat(400), ...message });
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, ids);
for (const id of ids) {
smsc.deliver(receiptBody(id));
}
const dlrs = await reported;
assert.deepEqual(dlrs.map(one => one.smsId), ids);
assert.deepEqual(merged, [], 'unrelated ids spell out no message to merge');
});
// Telesign answers only the first part of a concatenated submit with a message id.
test('hands back what landed where only the first segment is answered with one', async t => {
const id = '5cb0ea53b5d61093529174ca44e23871';
const smsc = await dummySmsc(t, { messageIds: [id] });
const session = await bindToSmsc(t, smsc.port);
const sent = await session.sendSms({ dlr: true, message: 'x'.repeat(400), ...message });
assert.equal(sent.err, undefined);
assert.equal(smsc.octets.length, 3, 'every segment goes out whatever the peer answers');
assert.deepEqual(sent.smsIds, [id, '', '']);
});
});
+31 -2
View File
@@ -1471,8 +1471,10 @@ describe('sendDlr()', () => {
});
});
function segment(reference: number, part: number, total: number): PduObject {
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
function segment(reference: number, part: number, total: number, width: 8 | 16 = 8): PduObject {
const udh = width === 8
? Buffer.from([0x05, 0x00, 0x03, reference, total, part])
: Buffer.from([0x06, 0x08, 0x04, reference >>> 8, reference & 0xff, total, part]);
const body = Buffer.concat([udh, Buffer.from('fragment')]);
return {
@@ -1544,6 +1546,11 @@ describe('where a segment says it is concatenated', () => {
test('names the spelling a segment was numbered by, alongside the reference', () => {
assert.deepEqual(concatOf(sarSegment(5, 2, 3)), { part: 2, reference: 5, spelling: 'sar', total: 3 });
assert.deepEqual(concatOf(segment(5, 2, 3)), { part: 2, reference: 5, spelling: 'udh', total: 3 });
assert.deepEqual(
concatOf(segment(0x2af1, 2, 3, 16)),
{ part: 2, reference: 0x2af1, spelling: 'udh', total: 3 },
'GSM 03.40 element 0x08 numbers a segment as element 0x00 does, two octets wider',
);
});
test('reads a segment carrying both spellings from its UDH', () => {
@@ -1632,6 +1639,28 @@ describe('reassembly bounds', () => {
assert.equal(third.smsId, collected.smsId);
});
// The header is stripped by its own declared length, so the wider element assembles identically.
test('assembles a message numbered by a 16-bit UDH reference', () => {
const reassembler = new Reassembler({
log: silentLog,
max: 10,
now: () => 0,
onLost: () => undefined,
timeout: 60_000,
});
const first = collectPdu(reassembler, segment(0x2af1, 1, 2, 16));
assert.ok(first.kept);
assert.equal(first.whole, undefined);
const collected = collectPdu(reassembler, segment(0x2af1, 2, 2, 16));
assert.ok(collected.kept);
assert.ok(collected.whole);
assert.equal(decodeSegments(collected.whole), 'fragmentfragment');
});
// A group the store cannot hold at all is refused, not accepted and then thrown away.
test('refuses a lone segment whose own arrival overruns the octet cap', () => {
const lost: LostGroup[] = [];
+11
View File
@@ -2471,6 +2471,17 @@ describe('merged delivery report bounds', () => {
assert.equal(dlrMerger.size, 0);
});
// Telesign answers only the first segment of a concatenated submit with a message id.
test('arms nothing for a send whose ids do not number one message', () => {
const dlrMerger = merger();
dlrMerger.expect(['5cb0ea53b5d61093529174ca44e23871', '', '']);
assert.equal(dlrMerger.size, 0, 'an id the peer never named numbers nothing');
dlrMerger.expect(['bf53ad8b-1', '40ccdce2-2']);
assert.equal(dlrMerger.size, 0, 'nor do ids numbered off a base each');
});
// A receipt for whole-3 would otherwise fill the slot whole-2 was registered for, truncating the report.
test('ignores a receipt for a part the send never registered', () => {
const dlrMerger = merger();