Files
smpp-js/interop-tests/findings/09-operator-fixtures.md
T
lilleman 5a0cf56754 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
2026-09-08 18:56:57 +02:00

209 lines
16 KiB
Markdown

# 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.