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:
@@ -198,9 +198,16 @@ exactly 140.
|
|||||||
preambles or restate what the code says.
|
preambles or restate what the code says.
|
||||||
- Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders.
|
- 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
|
- 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
|
the octets a test writes straight to a socket, the PDUs `objToPdu()` refuses to build included. So
|
||||||
waiting helpers each file carries are copies, tolerated because a wrong one fails that file's own
|
is the peer that answers on its own: `test/dummy-smsc.ts` is the one auto-answering SMSC, because
|
||||||
tests and nothing else.
|
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.
|
- `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
|
- 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.
|
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
|
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
|
||||||
absent one cannot land there.
|
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 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
|
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
|
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*`,
|
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
|
||||||
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
|
`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.
|
`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.
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ one id per segment:
|
|||||||
const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
|
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
|
`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
|
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
|
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
|
`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
|
`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
|
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
|
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,
|
`sm_length` 0 and puts the body in the `message_payload` TLV — SMPP's way of carrying up to 64 KB,
|
||||||
|
|||||||
@@ -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) |
|
| 6 | done | [06-python-php.md](findings/06-python-php.md) |
|
||||||
| 7 | done | [07-load.md](findings/07-load.md) |
|
| 7 | done | [07-load.md](findings/07-load.md) |
|
||||||
| 8 | dropped | [Untested](#untested) |
|
| 8 | dropped | [Untested](#untested) |
|
||||||
| 9–11 | not started | — |
|
| 9 | done | [09-operator-fixtures.md](findings/09-operator-fixtures.md) |
|
||||||
|
| 10–11 | not started | — |
|
||||||
|
|
||||||
Phases 1 to 5 were graded before `run.py` learned to fail an empty capture, so a run whose capture
|
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
|
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
@@ -10,15 +10,19 @@ import { paramNumber, paramText } from './defs/types.ts';
|
|||||||
import { udhLength } from './udh.ts';
|
import { udhLength } from './udh.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The seven-character status codes carried in a receipt's `stat:` field, mapped to the
|
* The status codes carried in a receipt's `stat:` field, mapped to the message_state values they
|
||||||
* message_state values they correspond to.
|
* correspond to. Two of them are not the seven characters the field is meant to hold.
|
||||||
*/
|
*/
|
||||||
const receiptStates: Record<string, MessageState> = {
|
const receiptStates: Record<string, MessageState> = {
|
||||||
ACCEPTD: 'ACCEPTED',
|
ACCEPTD: 'ACCEPTED',
|
||||||
DELETED: 'DELETED',
|
DELETED: 'DELETED',
|
||||||
|
// CM.com publishes this eight-character spelling of DELIVRD in its own code table.
|
||||||
|
DELIVERD: 'DELIVERED',
|
||||||
DELIVRD: 'DELIVERED',
|
DELIVRD: 'DELIVERED',
|
||||||
ENROUTE: 'ENROUTE',
|
ENROUTE: 'ENROUTE',
|
||||||
EXPIRED: 'EXPIRED',
|
EXPIRED: 'EXPIRED',
|
||||||
|
// Kaleyra and Route Mobile document it as a terminal failure; Appendix B does not define it.
|
||||||
|
FAILED: 'UNDELIVERABLE',
|
||||||
REJECTD: 'REJECTED',
|
REJECTD: 'REJECTED',
|
||||||
UNDELIV: 'UNDELIVERABLE',
|
UNDELIV: 'UNDELIVERABLE',
|
||||||
UNKNOWN: 'UNKNOWN',
|
UNKNOWN: 'UNKNOWN',
|
||||||
@@ -82,18 +86,21 @@ function toNumber(value: string | undefined): number | undefined {
|
|||||||
return Number.isFinite(parsed) ? parsed : 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 {
|
function receiptDate(value: string | undefined): Date | undefined {
|
||||||
if (value === undefined) return 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;
|
if (!match) return undefined;
|
||||||
|
|
||||||
const [, years, months, days, hours, minutes, seconds] = match;
|
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(
|
const date = new Date(Date.UTC(
|
||||||
century + Number(years),
|
year,
|
||||||
Number(months) - 1,
|
Number(months) - 1,
|
||||||
Number(days),
|
Number(days),
|
||||||
Number(hours),
|
Number(hours),
|
||||||
@@ -101,8 +108,9 @@ function receiptDate(value: string | undefined): Date | undefined {
|
|||||||
Number(seconds ?? 0),
|
Number(seconds ?? 0),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Date.UTC rolls 31 February over into March rather than refusing it.
|
// Date.UTC rolls 31 February into March rather than refusing it, and reads year 26 as 1926.
|
||||||
const rolled = date.getUTCMonth() !== Number(months) - 1
|
const rolled = date.getUTCFullYear() !== year
|
||||||
|
|| date.getUTCMonth() !== Number(months) - 1
|
||||||
|| date.getUTCDate() !== Number(days)
|
|| date.getUTCDate() !== Number(days)
|
||||||
|| date.getUTCHours() !== Number(hours)
|
|| date.getUTCHours() !== Number(hours)
|
||||||
|| date.getUTCMinutes() !== Number(minutes)
|
|| date.getUTCMinutes() !== Number(minutes)
|
||||||
|
|||||||
@@ -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', () => {
|
test('leaves an impossible receipt date undefined rather than rolling it over', () => {
|
||||||
const rolled = dlrFromPdu(deliverSm('id:x stat:DELIVRD done date:9902310000'));
|
const rolled = dlrFromPdu(deliverSm('id:x stat:DELIVRD done date:9902310000'));
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import net from 'node:net';
|
|
||||||
import test, { describe } from 'node:test';
|
import test, { describe } from 'node:test';
|
||||||
import type { PduObjectInput } from '../src/pdu.ts';
|
import type { PduObjectInput } from '../src/pdu.ts';
|
||||||
import type { SendSmsDeps } from '../src/send-sms.ts';
|
import type { SendSmsDeps } from '../src/send-sms.ts';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session.ts';
|
||||||
import type { SubmitMessagingMode } from '../src/defs/constants.ts';
|
import type { SubmitMessagingMode } from '../src/defs/constants.ts';
|
||||||
import type { TestContext } from 'node:test';
|
import type { TestContext } from 'node:test';
|
||||||
import { PduFramer } from '../src/pdu-framer.ts';
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
import { client } from '../src/client.ts';
|
|
||||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
|
||||||
import { consts, submitMessagingModes } from '../src/defs/constants.ts';
|
import { consts, submitMessagingModes } from '../src/defs/constants.ts';
|
||||||
import { paramNumber } from '../src/defs/types.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 { silentLog } from '../src/log.ts';
|
||||||
import { submitSms } from '../src/send-sms.ts';
|
import { submitSms } from '../src/send-sms.ts';
|
||||||
import { uuidv7 } from '../src/uuid.ts';
|
|
||||||
|
|
||||||
const from = '46701113311';
|
const from = '46701113311';
|
||||||
const to = '46709771337';
|
const to = '46709771337';
|
||||||
@@ -38,45 +34,11 @@ type BoundPeer = {
|
|||||||
session: Session;
|
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> {
|
async function boundToPeer(t: TestContext): Promise<BoundPeer> {
|
||||||
const octets: Buffer[] = [];
|
const smsc = await dummySmsc(t);
|
||||||
const sockets: net.Socket[] = [];
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
const listener = net.createServer(sock => {
|
|
||||||
const framer = new PduFramer();
|
|
||||||
|
|
||||||
sockets.push(sock);
|
return { octets: smsc.octets, session };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexOf(octets: Buffer[]): string[] {
|
function hexOf(octets: Buffer[]): string[] {
|
||||||
|
|||||||
@@ -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, '', '']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1471,8 +1471,10 @@ describe('sendDlr()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function segment(reference: number, part: number, total: number): PduObject {
|
function segment(reference: number, part: number, total: number, width: 8 | 16 = 8): PduObject {
|
||||||
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
|
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')]);
|
const body = Buffer.concat([udh, Buffer.from('fragment')]);
|
||||||
|
|
||||||
return {
|
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', () => {
|
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(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(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', () => {
|
test('reads a segment carrying both spellings from its UDH', () => {
|
||||||
@@ -1632,6 +1639,28 @@ describe('reassembly bounds', () => {
|
|||||||
assert.equal(third.smsId, collected.smsId);
|
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.
|
// 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', () => {
|
test('refuses a lone segment whose own arrival overruns the octet cap', () => {
|
||||||
const lost: LostGroup[] = [];
|
const lost: LostGroup[] = [];
|
||||||
|
|||||||
@@ -2471,6 +2471,17 @@ describe('merged delivery report bounds', () => {
|
|||||||
assert.equal(dlrMerger.size, 0);
|
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.
|
// 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', () => {
|
test('ignores a receipt for a part the send never registered', () => {
|
||||||
const dlrMerger = merger();
|
const dlrMerger = merger();
|
||||||
|
|||||||
Reference in New Issue
Block a user