diff --git a/AGENTS.md b/AGENTS.md
index 3112eef..ccac03f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/README.md b/README.md
index bd7a167..af6a8c1 100644
--- a/README.md
+++ b/README.md
@@ -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,
diff --git a/interop-tests/PLAN.md b/interop-tests/PLAN.md
index a241b46..20acad0 100644
--- a/interop-tests/PLAN.md
+++ b/interop-tests/PLAN.md
@@ -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) |
-| 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
never started would have scored green on the wire checks while its own assertions carried it. Every
diff --git a/interop-tests/findings/09-operator-fixtures.md b/interop-tests/findings/09-operator-fixtures.md
new file mode 100644
index 0000000..ae22177
--- /dev/null
+++ b/interop-tests/findings/09-operator-fixtures.md
@@ -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 `-` 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 `['', '', '']` — 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: err:
+ 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.
diff --git a/src/dlr.ts b/src/dlr.ts
index dca9cd6..1ef5b46 100644
--- a/src/dlr.ts
+++ b/src/dlr.ts
@@ -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 = {
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)
diff --git a/test/dlr.test.ts b/test/dlr.test.ts
index 0c23477..8e3e9a7 100644
--- a/test/dlr.test.ts
+++ b/test/dlr.test.ts
@@ -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'));
diff --git a/test/dummy-smsc.ts b/test/dummy-smsc.ts
new file mode 100644
index 0000000..7da2609
--- /dev/null
+++ b/test/dummy-smsc.ts
@@ -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 {
+ 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(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[0] = {},
+): Promise {
+ const { err, session } = await client({ ...options, port });
+
+ assert.equal(err, undefined);
+ assert.ok(session);
+ closeAfter(t, session);
+
+ return session;
+}
diff --git a/test/messaging-mode.test.ts b/test/messaging-mode.test.ts
index 5a85c19..4e00d76 100644
--- a/test/messaging-mode.test.ts
+++ b/test/messaging-mode.test.ts
@@ -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 {
- 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(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[] {
diff --git a/test/operator-receipts.test.ts b/test/operator-receipts.test.ts
new file mode 100644
index 0000000..753c82d
--- /dev/null
+++ b/test/operator-receipts.test.ts
@@ -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,
+ 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;
+};
+
+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(count: number, register: (push: (value: T) => void) => void): Promise {
+ const values: T[] = [];
+
+ return new Promise((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(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(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(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(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 - 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(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, '', '']);
+ });
+});
diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts
index 313b547..6a6079c 100644
--- a/test/session-extras.test.ts
+++ b/test/session-extras.test.ts
@@ -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[] = [];
diff --git a/test/session.test.ts b/test/session.test.ts
index d3ad37d..0669ca6 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -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();