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();