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:
@@ -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.
|
||||
Reference in New Issue
Block a user