@yaebal/test 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +400 -15
  2. package/lib/api.d.ts +83 -0
  3. package/lib/api.d.ts.map +1 -0
  4. package/lib/api.js +186 -0
  5. package/lib/api.js.map +1 -0
  6. package/lib/api.test.d.ts +2 -0
  7. package/lib/api.test.d.ts.map +1 -0
  8. package/lib/api.test.js +131 -0
  9. package/lib/api.test.js.map +1 -0
  10. package/lib/bot-messages.d.ts +42 -0
  11. package/lib/bot-messages.d.ts.map +1 -0
  12. package/lib/bot-messages.js +72 -0
  13. package/lib/bot-messages.js.map +1 -0
  14. package/lib/chat-actor.d.ts +45 -0
  15. package/lib/chat-actor.d.ts.map +1 -0
  16. package/lib/chat-actor.js +72 -0
  17. package/lib/chat-actor.js.map +1 -0
  18. package/lib/clock.d.ts +22 -0
  19. package/lib/clock.d.ts.map +1 -0
  20. package/lib/clock.js +72 -0
  21. package/lib/clock.js.map +1 -0
  22. package/lib/clock.test.d.ts +2 -0
  23. package/lib/clock.test.d.ts.map +1 -0
  24. package/lib/clock.test.js +69 -0
  25. package/lib/clock.test.js.map +1 -0
  26. package/lib/env.d.ts +100 -0
  27. package/lib/env.d.ts.map +1 -0
  28. package/lib/env.js +164 -0
  29. package/lib/env.js.map +1 -0
  30. package/lib/env.test.d.ts +2 -0
  31. package/lib/env.test.d.ts.map +1 -0
  32. package/lib/env.test.js +302 -0
  33. package/lib/env.test.js.map +1 -0
  34. package/lib/fetch.d.ts +3 -0
  35. package/lib/fetch.d.ts.map +1 -0
  36. package/lib/fetch.js +12 -0
  37. package/lib/fetch.js.map +1 -0
  38. package/lib/index.d.ts +19 -52
  39. package/lib/index.d.ts.map +1 -1
  40. package/lib/index.js +19 -115
  41. package/lib/index.js.map +1 -1
  42. package/lib/internal.d.ts +25 -0
  43. package/lib/internal.d.ts.map +1 -0
  44. package/lib/internal.js +19 -0
  45. package/lib/internal.js.map +1 -0
  46. package/lib/keyboard.d.ts +15 -0
  47. package/lib/keyboard.d.ts.map +1 -0
  48. package/lib/keyboard.js +25 -0
  49. package/lib/keyboard.js.map +1 -0
  50. package/lib/keyboard.test.d.ts +2 -0
  51. package/lib/keyboard.test.d.ts.map +1 -0
  52. package/lib/keyboard.test.js +31 -0
  53. package/lib/keyboard.test.js.map +1 -0
  54. package/lib/normalize.d.ts +10 -0
  55. package/lib/normalize.d.ts.map +1 -0
  56. package/lib/normalize.js +27 -0
  57. package/lib/normalize.js.map +1 -0
  58. package/lib/reactions.d.ts +3 -0
  59. package/lib/reactions.d.ts.map +1 -0
  60. package/lib/reactions.js +17 -0
  61. package/lib/reactions.js.map +1 -0
  62. package/lib/updates.d.ts +126 -0
  63. package/lib/updates.d.ts.map +1 -0
  64. package/lib/updates.js +200 -0
  65. package/lib/updates.js.map +1 -0
  66. package/lib/updates.test.d.ts +2 -0
  67. package/lib/updates.test.d.ts.map +1 -0
  68. package/lib/updates.test.js +72 -0
  69. package/lib/updates.test.js.map +1 -0
  70. package/lib/user-actor.d.ts +188 -0
  71. package/lib/user-actor.d.ts.map +1 -0
  72. package/lib/user-actor.js +465 -0
  73. package/lib/user-actor.js.map +1 -0
  74. package/lib/webhook.d.ts +18 -0
  75. package/lib/webhook.d.ts.map +1 -0
  76. package/lib/webhook.js +25 -0
  77. package/lib/webhook.js.map +1 -0
  78. package/lib/webhook.test.d.ts +2 -0
  79. package/lib/webhook.test.d.ts.map +1 -0
  80. package/lib/webhook.test.js +36 -0
  81. package/lib/webhook.test.js.map +1 -0
  82. package/package.json +7 -5
  83. package/src/api.test.ts +180 -0
  84. package/src/api.ts +289 -0
  85. package/src/bot-messages.ts +117 -0
  86. package/src/chat-actor.ts +101 -0
  87. package/src/clock.test.ts +80 -0
  88. package/src/clock.ts +118 -0
  89. package/src/env.test.ts +370 -0
  90. package/src/env.ts +235 -0
  91. package/src/fetch.ts +11 -0
  92. package/src/index.ts +79 -169
  93. package/src/internal.ts +43 -0
  94. package/src/keyboard.test.ts +35 -0
  95. package/src/keyboard.ts +38 -0
  96. package/src/normalize.ts +34 -0
  97. package/src/reactions.ts +18 -0
  98. package/src/updates.test.ts +107 -0
  99. package/src/updates.ts +354 -0
  100. package/src/user-actor.ts +702 -0
  101. package/src/webhook.test.ts +54 -0
  102. package/src/webhook.ts +48 -0
  103. package/lib/index.test.d.ts +0 -2
  104. package/lib/index.test.d.ts.map +0 -1
  105. package/lib/index.test.js +0 -66
  106. package/lib/index.test.js.map +0 -1
  107. package/src/index.test.ts +0 -101
package/README.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # @yaebal/test
2
2
 
3
- testing utilities for yaebal bots: a fake `Api` that records every call, update factories, and helpers to run middleware in isolation.
3
+ **the most complete test framework for Telegram bots on the market full stop.**
4
+
5
+ `@yaebal/test` wraps your bot in a `TestEnv` and hands you virtual **users** and **chats** that
6
+ send it *real* Telegram updates — messages, commands, media, reactions, inline-button clicks,
7
+ joins, payments — exactly the way real users would. every outgoing api call is intercepted (no
8
+ real HTTP, ever), recorded, and answered with sensible auto-stubs you can override or fail on
9
+ demand. a virtual clock lets you skip real time for TTL/retry tests. satellite plugins can ship
10
+ their own test fixtures. and if you need a raw update shape the actors don't cover yet, the whole
11
+ low-level fixture-builder layer is still there underneath.
12
+
13
+ zero dependency on any test runner or assertion library — works with `node:test`, vitest,
14
+ bun:test, jest, ava, anything that can `await` a promise and call `assert`.
4
15
 
5
16
  ## install
6
17
 
@@ -8,33 +19,407 @@ testing utilities for yaebal bots: a fake `Api` that records every call, update
8
19
  pnpm add -D @yaebal/test
9
20
  ```
10
21
 
11
- ## usage
22
+ ## quick start
12
23
 
13
24
  ```ts
14
- import { callbackUpdate, createContext, messageUpdate, mockApi, runMiddleware } from "@yaebal/test";
15
- import { expect, test } from "vitest";
25
+ import { Composer } from "@yaebal/core";
26
+ import { createTestEnv } from "@yaebal/test";
27
+ import { expect, test } from "vitest"; // or node:test, or whatever you use
28
+
29
+ const bot = new Composer().command("start", (ctx) => ctx.reply("Welcome!"));
16
30
 
17
31
  test("replies to /start", async () => {
18
- const { api, calls } = mockApi();
19
- const update = messageUpdate({ text: "/start", chatId: 42 });
20
- const ctx = createContext(update, api);
32
+ const env = createTestEnv(bot);
33
+ const linia = env.createUser({ firstName: "Linia" });
34
+
35
+ await linia.sendCommand("start");
36
+
37
+ expect(env.lastApiCall("sendMessage")?.params?.text).toBe("Welcome!");
38
+ });
39
+
40
+ test("clicking a button fires the callback handler", async () => {
41
+ const env = createTestEnv(bot);
42
+ const linia = env.createUser();
43
+
44
+ await linia.sendCommand("start");
45
+ const bubble = env.lastBotMessage({ withReplyMarkup: true });
46
+ if (bubble) await linia.on(bubble).clickByText("Next »");
47
+ });
48
+ ```
49
+
50
+ ## `createTestEnv(bot, options?)`
51
+
52
+ the orchestrator. wraps any `Composer`/`Bot`, intercepts every outgoing api call, and gives you
53
+ actor factories plus every assertion/stub helper below.
54
+
55
+ ```ts
56
+ const env = createTestEnv(bot, {
57
+ results?: Record<string, MockResult>, // seed permanent api replies
58
+ strictApi?: boolean, // throw on an unstubbed method with no builtin default
59
+ strictDispatch?: boolean, // throw if an update falls through with no handler
60
+ packs?: TestPack[], // satellite-plugin test fixtures, see below
61
+ });
62
+ ```
63
+
64
+ - **`env.createUser(options?)`** — a `UserActor` linked to the environment
65
+ - **`env.createChat(options)`** — a `ChatActor` (`group`/`supergroup`/`channel`/`private`)
66
+ - **`env.dispatch(update)`** / **`env.inject(update)`** — the escape hatch: ship a raw `Update`
67
+ through the bot, for shapes the actors don't cover yet
68
+ - **`env.users`** / **`env.chats`** — every actor created so far
69
+
70
+ ## actors — users drive the scenario
71
+
72
+ ```ts
73
+ const linia = env.createUser({ firstName: "Linia", username: "linia" });
74
+ ```
75
+
76
+ ### text, replies, commands
77
+
78
+ ```ts
79
+ await linia.sendMessage("hello");
80
+ await linia.sendMessage(group, "hello group"); // ChatActor as the first arg
81
+ await linia.sendReply(originalMsg, "thanks!"); // reply_to_message + same chat, inferred
82
+ await linia.sendCommand("start"); // text: "/start", bot_command entity
83
+ await linia.sendCommand("start", "ref42"); // text: "/start ref42"
84
+ ```
85
+
86
+ `sendMessage`/`sendReply`/`sendCommand` all accept a plain string **or a `format()` result** from
87
+ `@yaebal/core` (or anything shaped like one) — entities are extracted automatically, the same way
88
+ a real client would attach them:
89
+
90
+ ```ts
91
+ import { bold, format } from "@yaebal/core";
92
+
93
+ await linia.sendMessage(format`Check out ${bold("this")}`); // ctx.message.entities is populated
94
+ ```
95
+
96
+ ### media
97
+
98
+ every method auto-generates `file_id`/`file_unique_id` and the fields Telegram requires. all
99
+ accept an optional leading `ChatActor` to target a specific chat.
100
+
101
+ ```ts
102
+ await linia.sendPhoto({ caption: "Look!", spoiler: true });
103
+ await linia.sendVideo();
104
+ await linia.sendDocument();
105
+ await linia.sendVoice();
106
+ await linia.sendAudio();
107
+ await linia.sendAnimation();
108
+ await linia.sendVideoNote();
109
+ await linia.sendSticker({ emoji: "🔥" });
110
+ await linia.sendLocation({ latitude: 48.8566, longitude: 2.3522 });
111
+ await linia.sendContact({ phone_number: "+1234567890", first_name: "Bob" });
112
+ await linia.sendVenue({ location: { latitude: 48.85, longitude: 2.35 }, title: "Louvre", address: "Paris" });
113
+ await linia.sendDice("🎯");
114
+
115
+ await linia.sendMediaGroup(group, [
116
+ { photo: [{ file_id: "f1", file_unique_id: "u1", width: 800, height: 600 }] },
117
+ { photo: [{ file_id: "f2", file_unique_id: "u2", width: 800, height: 600 }] },
118
+ ]); // one update per item, all sharing media_group_id
119
+ ```
120
+
121
+ ### editing, forwarding, pinning
122
+
123
+ ```ts
124
+ const msg = await linia.sendMessage("original");
125
+ await linia.editMessage(msg, "edited"); // dispatches edited_message
126
+ await linia.forwardMessage(msg, group); // forward_origin set, defaults to linia's own PM
127
+ await linia.pinMessage(msg); // service message with pinned_message set
128
+ ```
129
+
130
+ ### buttons, reactions, joins
131
+
132
+ ```ts
133
+ await linia.click("vote:up", msg); // raw callback_data
134
+ await linia.on(msg).click("vote:up"); // same, message pre-bound
135
+ await linia.on(msg).clickByText("Next »"); // scans msg's inline_keyboard for the label
136
+
137
+ await linia.react("👍", msg); // old_reaction inferred from linia's last react()
138
+ await linia.react(["👍", "🔥"], msg);
139
+ await linia.react([], msg); // clear all of linia's reactions
140
+
141
+ await linia.join(group); // chat.members + a chat_member update + service msg
142
+ await linia.leave(group);
143
+ ```
144
+
145
+ reaction state is tracked per-message automatically — you never spell out `old_reaction` by hand,
146
+ and it's tracked independently per user:
147
+
148
+ ```ts
149
+ await linia.react("👍", msg); // old: [], new: ["👍"]
150
+ await linia.react("❤", msg); // old: ["👍"], new: ["❤"] — inferred, not passed in
151
+ await bob.react("🔥", msg); // linia's and bob's state don't interfere
152
+ ```
153
+
154
+ ### inline mode & payments
155
+
156
+ ```ts
157
+ await linia.sendInlineQuery("cats", group); // chat_type derived from group.type
158
+ await linia.chooseInlineResult("result-1", "cats");
159
+
160
+ await linia.sendPreCheckoutQuery({ currency: "XTR", total_amount: 100, invoice_payload: "sub" });
161
+ await linia.sendShippingQuery({ invoice_payload: "physical_item" });
162
+
163
+ // the full flow: pre_checkout_query → verifies the bot answered { ok: true } → successful_payment.
164
+ // throws if the bot never answers, or answers with ok: false — exactly like real Telegram would
165
+ // never deliver successful_payment in that case.
166
+ await linia.sendSuccessfulPayment({ invoice_payload: "sub_monthly" });
167
+ ```
168
+
169
+ ### `.in(chat)` — scope every send to one chat
170
+
171
+ ```ts
172
+ const group = env.createChat({ type: "group", title: "devs" });
173
+
174
+ await linia.in(group).sendMessage("morning team");
175
+ await linia.in(group).sendCommand("help");
176
+ await linia.in(group).sendPhoto({ caption: "lunch" });
177
+ await linia.in(group).join();
178
+ await linia.in(group).on(msg).clickByText("yes");
179
+ ```
180
+
181
+ ### `.on(message)` — scope click/react/edit/forward/pin to one message
182
+
183
+ ```ts
184
+ await linia.on(msg).click("action:1");
185
+ await linia.on(msg).clickByText("Next »");
186
+ await linia.on(msg).react("👍");
187
+ await linia.on(msg).editMessage("updated");
188
+ ```
189
+
190
+ ## chats
191
+
192
+ ```ts
193
+ const group = env.createChat({ type: "group", title: "devs" });
194
+ const channel = env.createChat({ type: "channel", title: "News", username: "news" });
195
+ ```
196
+
197
+ - **`chat.members`** — the `Set<UserActor>` currently joined (via `.join()`)
198
+ - **`chat.setMembership(userId, { status, since? })`** / **`chat.membershipOf(user)`** — track
199
+ arbitrary membership status for `getChatMember`-style assertions
200
+ - **`chat.post(text)`** — an anonymous channel post (`update.channel_post`, no `from` — matches
201
+ real Telegram). throws on non-channel chats.
21
202
 
22
- await runMiddleware(bot, ctx);
203
+ ## inspecting what the bot did
23
204
 
24
- expect(calls[0]?.method).toBe("sendMessage");
205
+ ```ts
206
+ await linia.sendMessage("hi");
207
+
208
+ env.apiCalls; // every recorded { method, params, result | error, at }
209
+ env.lastApiCall("sendMessage"); // most recent call to a method (or overall, with no arg)
210
+ env.callsTo("sendMessage"); // every call to a method, in order
211
+ env.clearApiCalls(); // reset between logical phases of a test
212
+ ```
213
+
214
+ ### `env.lastBotMessage(query?)` — the bot's own messages, live
215
+
216
+ returns a `BotMessage` mirror of the bot's most recent `send*`/`forwardMessage`/`copyMessage` —
217
+ populated straight from the outgoing params, no `onApi` override required. **it's kept in sync in
218
+ place** as `editMessageText`/`editMessageCaption`/`editMessageReplyMarkup` calls land, even for a
219
+ reference captured *before* the edit — so `user.on(bubble).clickByText(...)` always sees the
220
+ current buttons:
221
+
222
+ ```ts
223
+ bot.on("message", (ctx) =>
224
+ ctx.send("Pick:", { reply_markup: { inline_keyboard: [[{ text: "Next", callback_data: "next" }]] } }),
225
+ );
226
+ bot.callbackQuery("next", async (ctx) => {
227
+ const { chat, message_id } = ctx.callbackQuery.message;
228
+ await ctx.api.call("editMessageText", { chat_id: chat.id, message_id, text: "Done!" });
25
229
  });
26
230
 
27
- test("callback query", async () => {
28
- const { api, calls } = mockApi();
29
- const update = callbackUpdate({ data: "vote:up", chatId: 1 });
30
- const ctx = createContext(update, api);
231
+ await linia.sendMessage("hi");
232
+ const bubble = env.lastBotMessage()!;
233
+ await linia.on(bubble).clickByText("Next");
234
+ bubble.text; // "Done!" — same object, mutated in place
235
+ ```
236
+
237
+ filters (all optional, combined with AND): `{ chat }` scope to a chat; `{ withReplyMarkup: true }`
238
+ skip plain status messages and find the last interactive bubble; `{ where: (call) => boolean }`
239
+ for an arbitrary predicate over the call that produced/last touched the message.
240
+ **`env.botMessage(chatId, messageId)`** looks one up directly.
241
+
242
+ ## mocking the api
243
+
244
+ without any setup, `@yaebal/test` returns sensible auto-stubs: an auto-incrementing `message_id`
245
+ for `send*`/`copyMessage`/`forwardMessage`, `true` for `answerCallbackQuery`, a stub bot for
246
+ `getMe`, `{}` otherwise.
247
+
248
+ ### `env.onApi(method, reply, opts?)` — override a reply
249
+
250
+ ```ts
251
+ env.onApi("getMe", { id: 7, is_bot: true, first_name: "MyBot", username: "my_bot" });
252
+ env.onApi("sendMessage", (params) => ({ message_id: 1, date: 0, chat: { id: params.chat_id, type: "private" }, text: params.text }));
253
+ ```
254
+
255
+ permanent by default; `{ times: 1 }` (or any N) makes it a one-shot that then falls back to the
256
+ next queued or permanent reply — perfect for "first call fails, second succeeds":
257
+
258
+ ```ts
259
+ env.onApi("sendMessage", apiError(429, "Too Many Requests", { retry_after: 1 }), { times: 1 });
260
+ env.onApi("sendMessage", { message_id: 1, date: 0, chat: { id: 1, type: "private" } });
261
+ ```
262
+
263
+ ### `apiError(code, description, parameters?)` — simulate a real Telegram failure
264
+
265
+ ```ts
266
+ import { apiError } from "@yaebal/test";
267
+ import { TelegramError } from "@yaebal/core";
268
+
269
+ env.onApi("sendMessage", apiError(403, "Forbidden: bot was blocked by the user"));
270
+
271
+ // the bot sees a real TelegramError (TestApiError extends it):
272
+ try {
273
+ await ctx.reply("hi");
274
+ } catch (error) {
275
+ error instanceof TelegramError; // true
276
+ error.code; // 403
277
+ }
278
+ ```
279
+
280
+ **`env.offApi(method?)`** drops a method's override (or every override, with no argument).
281
+
282
+ ### `strictApi` / `strictDispatch`
283
+
284
+ ```ts
285
+ const env = createTestEnv(bot, { strictApi: true }); // throw on an unstubbed method — catch "forgot to mock this"
286
+ const env2 = createTestEnv(bot, { strictDispatch: true }); // throw if no handler consumed the dispatched update
287
+ ```
288
+
289
+ ## the virtual clock — skip real time
290
+
291
+ TTL expirations, retry backoffs, debounces — none of it should cost real wall-clock time in a
292
+ test suite.
293
+
294
+ ```ts
295
+ const env = createTestEnv(bot);
296
+ env.useFakeTimers(); // arm it *before* the code under test schedules a timer you want to control
297
+
298
+ await linia.sendCommand("start"); // handler calls setTimeout(..., 60 * 60 * 1000) internally
31
299
 
32
- await runMiddleware(bot, ctx);
300
+ await env.advanceTime(60 * 60 * 1000); // fires it instantly
301
+ ```
302
+
303
+ `advanceTime` arms the clock for you if you haven't already (handy when you're timing something
304
+ your own test code schedules, after the fact). Intervals re-arm and may fire multiple times in one
305
+ `advance()` call; timers scheduled from inside a firing callback are picked up by the same call.
306
+ **Always call `env.shutdown()` in your teardown** — it restores the real `Date.now`/timers so the
307
+ next test isn't left on virtual time.
308
+
309
+ For standalone use outside a `TestEnv`:
310
+
311
+ ```ts
312
+ import { installTestClock } from "@yaebal/test";
313
+
314
+ const clock = installTestClock(1_700_000_000_000);
315
+ setInterval(() => {/* … */}, 1000);
316
+ await clock.advance(3500); // fires 3 times
317
+ clock.restore();
318
+ ```
319
+
320
+ ## satellite-plugin test packs
321
+
322
+ a `TestPack` is an explicit (never a global registry — yaebal doesn't do implicit plugin wiring)
323
+ hook a plugin package can ship so its own tests — or yours — get sensible fixtures for free:
324
+
325
+ ```ts
326
+ import type { TestPack } from "@yaebal/test";
327
+
328
+ export function myPluginTestPack(options?: MyPluginOptions): TestPack {
329
+ return {
330
+ name: "my-plugin",
331
+ setup(env) {
332
+ installMyPlugin(env.api, options); // wire whatever the plugin needs onto env.api/env
333
+ },
334
+ };
335
+ }
336
+ ```
337
+
338
+ ```ts
339
+ const env = createTestEnv(bot, { packs: [myPluginTestPack()] });
340
+ ```
341
+
342
+ `@yaebal/again` ships one: `import { againTestPack } from "@yaebal/again/test-pack"` wires
343
+ `autoRetry` onto `env.api` so retry tests don't call it by hand.
344
+
345
+ ## fixture builders — the escape hatch
33
346
 
34
- expect(calls[0]?.method).toBe("answerCallbackQuery");
347
+ every actor method is sugar over a raw `Update`. reach for these directly for shapes the actors
348
+ don't cover, or full field-by-field control — `createUpdate` fills in a fresh `update_id`, the
349
+ rest build one payload key each: `messageUpdate`, `editedMessageUpdate`, `channelPostUpdate`,
350
+ `editedChannelPostUpdate`, `callbackUpdate`, `inlineQueryUpdate`, `chosenInlineResultUpdate`,
351
+ `shippingQueryUpdate`, `preCheckoutQueryUpdate`, `pollUpdate`, `pollAnswerUpdate`,
352
+ `myChatMemberUpdate`, `chatMemberUpdate`, `chatJoinRequestUpdate`. `detectUpdateType` infers which
353
+ payload key an update carries, and `buildUser` builds a `User` with an auto-allocated id.
354
+
355
+ ```ts
356
+ import { createUpdate } from "@yaebal/test";
357
+
358
+ const custom = createUpdate({
359
+ edited_message: { message_id: 1, date: 0, chat: { id: 1, type: "private" } },
35
360
  });
361
+ await env.dispatch(custom);
362
+ ```
363
+
364
+ `findButton(markup, match)` searches a `reply_markup` (plain JSON, or a builder instance like
365
+ `InlineKeyboard` — unwrapped via `toJSON()` automatically) for a button whose text matches a
366
+ string or regex, returning it with its `row`/`col`. this is what `clickByText` uses internally.
367
+
368
+ ## webhooks & runners
369
+
370
+ ```ts
371
+ import { webhookCallback } from "@yaebal/core";
372
+ import { messageUpdate, webhookRequest } from "@yaebal/test";
373
+
374
+ const handler = webhookCallback(bot, { secretToken: "s3cret" });
375
+ const res = await handler(webhookRequest(messageUpdate({ text: "hi" }), { secretToken: "s3cret" }));
376
+ assert.equal(res.status, 200);
36
377
  ```
37
378
 
379
+ `collectUpdates()` gives a minimal `UpdateSink` (the `{ handleUpdate }` shape webhooks/runners
380
+ expect) that just records what it receives. `withFetch(handler, fn)` stubs `globalThis.fetch` for
381
+ the duration of `fn`, restoring it after — even if `fn` throws — for code that proxies uploads or
382
+ downloads.
383
+
384
+ ## api reference
385
+
386
+ | export | signature | description |
387
+ |:---|:---|:---|
388
+ | `createTestEnv` | `(bot, options?) => TestEnv` | the main entry point |
389
+ | `TestEnv` | class | `api`, `apiCalls`, `hooks`, `users`, `chats`, `createUser`, `createChat`, `dispatch`/`inject`, `onApi`/`offApi`, `lastApiCall`/`callsTo`/`clearApiCalls`, `lastBotMessage`/`botMessage`, `useFakeTimers`/`advanceTime`, `onPostDispatch`, `answeredPreCheckoutQuery`, `shutdown` |
390
+ | `UserActor` | class | see actors above |
391
+ | `ChatActor` | class | `id`, `type`, `title?`, `username?`, `members`, `setMembership`/`membershipOf`, `post` |
392
+ | `apiError` | `(code, description, parameters?) => ApiErrorSentinel` | simulate a real Telegram error response |
393
+ | `isApiErrorSentinel` | `(value) => value is ApiErrorSentinel` | typeguard for a stored `apiError(...)` |
394
+ | `TestApiError` | class extends `TelegramError` | adds `.parameters` |
395
+ | `installTestClock` | `(startAt?) => TestClock` | standalone virtual clock: `.now()`, `.advance(ms)`, `.restore()` |
396
+ | `mockApi` | `(options?) => MockApi` | the fake `Api` underneath `TestEnv` — usable standalone |
397
+ | `findButton` | `(markup, match) => FoundButton \| undefined` | find an inline keyboard button by text |
398
+ | `toPlain` | `(value) => T` | unwrap a builder's `toJSON()`, or pass through |
399
+ | `createUpdate` / `messageUpdate` / … | see fixture builders above | raw update construction |
400
+ | `detectUpdateType` | `(update) => UpdateName` | infer the payload key; defaults to `"message"` |
401
+ | `collectUpdates` | `() => UpdateCollector` | a minimal `UpdateSink` that records what it receives |
402
+ | `webhookRequest` | `(update, options?) => Request` | build a webhook POST request carrying an update |
403
+ | `withFetch` | `(handler, fn) => Promise<T>` | stub `globalThis.fetch` for `fn`, restoring it after |
404
+
405
+ ## upgrading from 0.1.x
406
+
407
+ `@yaebal/test` `0.2` replaces the old flat api (`mockApi()` + `createContext()` + `messageUpdate()` + `runMiddleware()` wired together by hand) with the actor-driven `TestEnv` above:
408
+
409
+ ```diff
410
+ - const { api, calls } = mockApi();
411
+ - await runMiddleware(bot, createContext(messageUpdate({ text: "/start" }), api));
412
+ - assert.equal(calls[0]?.method, "sendMessage");
413
+ + const env = createTestEnv(bot);
414
+ + await env.createUser().sendCommand("start");
415
+ + assert.equal(env.lastApiCall("sendMessage")?.method, "sendMessage");
416
+ ```
417
+
418
+ `mockApi`, `createUpdate`/`messageUpdate`/etc., `findButton`, `webhookRequest`, `collectUpdates`,
419
+ and `withFetch` are all still here, unchanged in spirit — only `createContext`, `messageContext`,
420
+ `callbackContext`, and `runMiddleware` are gone, folded into `TestEnv.dispatch`/actors. `setResult`
421
+ is now `onApi`.
422
+
38
423
  ---
39
424
 
40
425
  part of [**yaebal**](https://github.com/neverlane/yaebal) — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
package/lib/api.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { type AfterHook, type Api, type BeforeHook, type ErrorHook, TelegramError } from "@yaebal/core";
2
+ /** a single recorded api call: the method, the params it was given, and how it resolved. */
3
+ export interface RecordedCall {
4
+ method: string;
5
+ params: Record<string, unknown> | undefined;
6
+ /** the resolved result, if the call succeeded. */
7
+ result?: unknown;
8
+ /** the thrown error, if the call failed (and no `onError` hook rescued it). */
9
+ error?: unknown;
10
+ /** ms since epoch when the call was made — honors an installed {@link TestClock}. */
11
+ at: number;
12
+ }
13
+ /** a canned result for one method: a static value, an {@link ApiErrorSentinel}/`Error`, or a function of `(params, attempt)`. */
14
+ export type MockResult = unknown | Error | ApiErrorSentinel | ((params: Record<string, unknown> | undefined, attempt: number) => unknown);
15
+ /** the shape `apiError(...)` returns — a sentinel telling the mock to throw a {@link TestApiError}. */
16
+ export interface ApiErrorSentinel {
17
+ readonly __yaebalApiError: true;
18
+ code: number;
19
+ description: string;
20
+ parameters?: Record<string, unknown>;
21
+ }
22
+ /**
23
+ * simulate a real Telegram Bot API error response — the bot sees exactly what it would see
24
+ * from the real api: a thrown {@link TestApiError} (a {@link TelegramError} subclass).
25
+ *
26
+ * @example
27
+ * env.onApi("sendMessage", apiError(403, "Forbidden: bot was blocked by the user"));
28
+ * env.onApi("sendMessage", apiError(429, "Too Many Requests", { retry_after: 30 }));
29
+ */
30
+ export declare function apiError(code: number, description: string, parameters?: Record<string, unknown>): ApiErrorSentinel;
31
+ /** typeguard for a stored `apiError(...)` sentinel. */
32
+ export declare function isApiErrorSentinel(value: unknown): value is ApiErrorSentinel;
33
+ /** a {@link TelegramError} carrying the optional `parameters` bag (e.g. `retry_after`) real errors ship. */
34
+ export declare class TestApiError extends TelegramError {
35
+ readonly parameters?: Record<string, unknown>;
36
+ constructor(method: string, code: number, description: string, parameters?: Record<string, unknown>);
37
+ }
38
+ /** options accepted by `{ times }` overrides: one-shot (or N-shot) replies. */
39
+ export interface OnApiOptions {
40
+ /** consume this override for exactly `times` calls, then fall back to the next queued/permanent reply. default: permanent (never expires). */
41
+ times?: number;
42
+ }
43
+ export interface MockApiOptions {
44
+ /** per-method canned results/errors, keyed by method name — seeds the permanent override, same as calling `onApi(method, result)` for each entry. */
45
+ results?: Record<string, MockResult>;
46
+ /** throw instead of falling back to `{}` when a method has no builtin default and no override registered. catches "forgot to stub this" in tests that care. default `false`. */
47
+ strictApi?: boolean;
48
+ /** clock to timestamp recorded calls with; defaults to the real `Date.now`. */
49
+ now?: () => number;
50
+ }
51
+ /** result of {@link mockApi}: the fake `api`, its recorded calls, and inspection/stubbing helpers. */
52
+ export interface MockApi {
53
+ api: Api;
54
+ calls: RecordedCall[];
55
+ /** hooks registered on `api` via `before`/`after`/`onError` — inspect them or invoke them yourself. */
56
+ hooks: {
57
+ before: BeforeHook[];
58
+ after: AfterHook[];
59
+ onError: ErrorHook[];
60
+ };
61
+ /** the most recent recorded call, optionally filtered to a method. */
62
+ lastCall(method?: string): RecordedCall | undefined;
63
+ /** every recorded call to a given method, in call order. */
64
+ callsTo(method: string): RecordedCall[];
65
+ /** override a method's reply. permanent unless `opts.times` is given (then it's consumed after `times` calls and the previous permanent, if any, resumes). */
66
+ onApi(method: string, reply: MockResult, opts?: OnApiOptions): void;
67
+ /** drop a method's overrides (or every method's, if none given) — reverts to the builtin default. */
68
+ offApi(method?: string): void;
69
+ /** clear recorded calls and per-method attempt counters. keeps hooks and overrides. */
70
+ reset(): void;
71
+ }
72
+ /**
73
+ * a fake {@link Api} whose every method records `{ method, params, result | error, at }` into
74
+ * `calls` and resolves to a sensible default (auto-incrementing `message_id` for `send*`, `true`
75
+ * for `answerCallbackQuery`, `{}` otherwise) — or to whatever `onApi`/`options.results` says.
76
+ * `before`/`after`/`onError` are real hook registrars (not no-ops): register a hook the same way
77
+ * you would on the production `Api` and it actually runs, including retries requested by an
78
+ * `onError` hook. the mock never actually waits on a requested `delayMs` — retries settle
79
+ * instantly, so tests stay fast (pair with {@link installTestClock} if the code under test
80
+ * schedules the retry itself via `setTimeout`).
81
+ */
82
+ export declare function mockApi(options?: MockApiOptions): MockApi;
83
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}