@yaebal/test 0.1.0 → 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 +333 -160
  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 -205
  39. package/lib/index.d.ts.map +1 -1
  40. package/lib/index.js +19 -391
  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 -630
  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 -213
  106. package/lib/index.test.js.map +0 -1
  107. package/src/index.test.ts +0 -320
package/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # @yaebal/test
2
2
 
3
- testing utilities for yaebal bots a fake `Api` that records every call (and can drive real
4
- `before`/`after`/`onError` hooks, simulate failures, and retries), update factories for every
5
- update kind telegram sends, context builders, and helpers for webhook/keyboard assertions.
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.
6
12
 
7
13
  zero dependency on any test runner or assertion library — works with `node:test`, vitest,
8
14
  bun:test, jest, ava, anything that can `await` a promise and call `assert`.
@@ -13,239 +19,406 @@ bun:test, jest, ava, anything that can `await` a promise and call `assert`.
13
19
  pnpm add -D @yaebal/test
14
20
  ```
15
21
 
16
- ## usage
22
+ ## quick start
17
23
 
18
24
  ```ts
19
- import { callbackUpdate, createContext, messageUpdate, mockApi, runMiddleware } from "@yaebal/test";
25
+ import { Composer } from "@yaebal/core";
26
+ import { createTestEnv } from "@yaebal/test";
20
27
  import { expect, test } from "vitest"; // or node:test, or whatever you use
21
28
 
29
+ const bot = new Composer().command("start", (ctx) => ctx.reply("Welcome!"));
30
+
22
31
  test("replies to /start", async () => {
23
- const { api, calls } = mockApi();
24
- const update = messageUpdate({ text: "/start", chatId: 42 });
25
- const ctx = createContext(update, api);
32
+ const env = createTestEnv(bot);
33
+ const linia = env.createUser({ firstName: "Linia" });
34
+
35
+ await linia.sendCommand("start");
26
36
 
27
- await runMiddleware(bot, ctx);
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();
28
43
 
29
- expect(calls[0]?.method).toBe("sendMessage");
44
+ await linia.sendCommand("start");
45
+ const bubble = env.lastBotMessage({ withReplyMarkup: true });
46
+ if (bubble) await linia.on(bubble).clickByText("Next »");
30
47
  });
48
+ ```
31
49
 
32
- test("callback query", async () => {
33
- const { api, calls } = mockApi();
34
- const update = callbackUpdate({ data: "vote:up", chatId: 1 });
35
- const ctx = createContext(update, api);
50
+ ## `createTestEnv(bot, options?)`
36
51
 
37
- await runMiddleware(bot, ctx);
52
+ the orchestrator. wraps any `Composer`/`Bot`, intercepts every outgoing api call, and gives you
53
+ actor factories plus every assertion/stub helper below.
38
54
 
39
- expect(calls[0]?.method).toBe("answerCallbackQuery");
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
40
61
  });
41
62
  ```
42
63
 
43
- or skip a step with the `*Context` shortcuts:
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
44
71
 
45
72
  ```ts
46
- import { messageContext, runMiddleware } from "@yaebal/test";
73
+ const linia = env.createUser({ firstName: "Linia", username: "linia" });
74
+ ```
47
75
 
48
- const ctx = messageContext({ text: "/start", chatId: 42 });
49
- await runMiddleware(bot, ctx);
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"
50
84
  ```
51
85
 
52
- ## mockApi
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:
53
89
 
54
- `mockApi(options?)` returns a fake `Api` plus inspection helpers. Every method records
55
- `{ method, params }` into `calls` and resolves to a sensible default: an auto-incrementing
56
- `{ message_id }` for `send*`/`copyMessage`/`forwardMessage`, `true` for `answerCallbackQuery`,
57
- a stub bot for `getMe`, `{}` otherwise.
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.
58
100
 
59
101
  ```ts
60
- const { api, calls, hooks, lastCall, callsTo, setResult, reset } = mockApi();
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
61
119
  ```
62
120
 
63
- - **`calls`** — every recorded call, in order.
64
- - **`lastCall(method?)`** — the most recent call, optionally filtered to a method.
65
- - **`callsTo(method)`** — every call to a given method, in order.
66
- - **`setResult(method, result)`** — override a method's canned result/error after creation.
67
- - **`reset()`** — clears `calls` and per-method attempt counters (keeps hooks and overrides).
68
- - **`hooks`** — the `before`/`after`/`onError` arrays your code under test registered on the api.
121
+ ### editing, forwarding, pinning
69
122
 
70
- ### canned results & error simulation
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
+ ```
71
129
 
72
- Pass `results` to control exactly what a method returns — a static value, an `Error` (which
73
- makes the call throw, e.g. `TelegramError`), or a function of `(params, attempt)`:
130
+ ### buttons, reactions, joins
74
131
 
75
132
  ```ts
76
- import { TelegramError } from "@yaebal/core";
77
- import { mockApi } from "@yaebal/test";
78
-
79
- const { api } = mockApi({
80
- results: {
81
- sendMessage: { message_id: 42 },
82
- getChat: () => ({ id: 1, type: "private" }),
83
- // fails twice, then succeeds — great for testing retry plugins like @yaebal/again
84
- getMe: (params, attempt) =>
85
- attempt <= 2 ? new TelegramError("getMe", 429, "retry after 0") : { id: 1, is_bot: true, first_name: "bot" },
86
- },
87
- });
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);
88
143
  ```
89
144
 
90
- ### real hooks, not no-ops
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
+ ```
91
153
 
92
- Unlike a bare stub, `before`/`after`/`onError` registered on a `mockApi()` actually run through
93
- the call pipeline — register a hook exactly as you would on the production `Api` and it fires,
94
- including retries requested by an `onError` hook (see [`@yaebal/again`](../again)'s retry
95
- policy). The mock never actually waits on a requested `delayMs`, so retry tests settle instantly:
154
+ ### inline mode & payments
96
155
 
97
156
  ```ts
98
- const { api } = mockApi({ results: { sendMessage: new TelegramError("sendMessage", 429, "retry after 0") } });
157
+ await linia.sendInlineQuery("cats", group); // chat_type derived from group.type
158
+ await linia.chooseInlineResult("result-1", "cats");
99
159
 
100
- api.onError((_method, _error, attempt) => (attempt === 1 ? { retry: true } : undefined));
160
+ await linia.sendPreCheckoutQuery({ currency: "XTR", total_amount: 100, invoice_payload: "sub" });
161
+ await linia.sendShippingQuery({ invoice_payload: "physical_item" });
101
162
 
102
- await api.sendMessage({ chat_id: 1 }); // retries once, then throws (no result override for attempt 2)
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" });
103
167
  ```
104
168
 
105
- ## contexts
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
+ ```
106
180
 
107
- `createContext(update, api?, updateType?)` wraps an `Update` in a core `Context`. The api
108
- defaults to a fresh `mockApi().api`; the update type is auto-detected via `detectUpdateType`
109
- unless you pass `updateType`. Run a composer's middleware against it with
110
- `runMiddleware(composer, ctx)` — it resolves once the chain settles.
181
+ ### `.on(message)` scope click/react/edit/forward/pin to one message
111
182
 
112
- `messageContext(options?, api?)` and `callbackContext(options?, api?)` build the update and the
113
- context in one call, for the two most common cases.
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
+ ```
114
189
 
115
- ## building updates
190
+ ## chats
116
191
 
117
- Factories exist for every update kind telegram sends:
192
+ ```ts
193
+ const group = env.createChat({ type: "group", title: "devs" });
194
+ const channel = env.createChat({ type: "channel", title: "News", username: "news" });
195
+ ```
118
196
 
119
- | factory | update key |
120
- |:---------------------------|:-----------------------|
121
- | `messageUpdate` | `message` |
122
- | `editedMessageUpdate` | `edited_message` |
123
- | `channelPostUpdate` | `channel_post` |
124
- | `editedChannelPostUpdate` | `edited_channel_post` |
125
- | `callbackUpdate` | `callback_query` |
126
- | `inlineQueryUpdate` | `inline_query` |
127
- | `chosenInlineResultUpdate` | `chosen_inline_result` |
128
- | `shippingQueryUpdate` | `shipping_query` |
129
- | `preCheckoutQueryUpdate` | `pre_checkout_query` |
130
- | `pollUpdate` | `poll` |
131
- | `pollAnswerUpdate` | `poll_answer` |
132
- | `myChatMemberUpdate` | `my_chat_member` |
133
- | `chatMemberUpdate` | `chat_member` |
134
- | `chatJoinRequestUpdate` | `chat_join_request` |
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.
135
202
 
136
- `createUpdate` is the escape hatch for anything else, filling in a fresh `update_id`.
137
- `detectUpdateType` infers which payload key an update carries.
203
+ ## inspecting what the bot did
138
204
 
139
205
  ```ts
140
- import { createUpdate, detectUpdateType, pollUpdate } from "@yaebal/test";
206
+ await linia.sendMessage("hi");
141
207
 
142
- const poll = pollUpdate({ question: "coffee or tea?", options: ["coffee", "tea"] });
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
+ ```
143
213
 
144
- // hand-build any update shape; update_id is filled in for you
145
- const custom = createUpdate({
146
- edited_message: { message_id: 1, date: 0, chat: { id: 1, type: "private" } },
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!" });
147
229
  });
148
230
 
149
- detectUpdateType(custom); // → "edited_message"
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
150
235
  ```
151
236
 
152
- ## inline keyboards
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
153
243
 
154
- `findButton(markup, match)` searches a `reply_markup`-shaped object (e.g. from a recorded call's
155
- params) for a button whose text matches a string or regex, returning it with its `row`/`col`:
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
156
249
 
157
250
  ```ts
158
- import { findButton, messageContext, mockApi, runMiddleware } from "@yaebal/test";
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
+ ```
159
254
 
160
- const { api, calls } = mockApi();
161
- await runMiddleware(bot, messageContext({ text: "/menu" }, api));
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":
162
257
 
163
- const next = findButton(calls[0]?.params?.reply_markup, "Next »");
164
- assert.equal(next?.callback_data, "page:2");
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" } });
165
261
  ```
166
262
 
167
- ## webhooks & runners
263
+ ### `apiError(code, description, parameters?)` — simulate a real Telegram failure
168
264
 
169
- `webhookRequest(update, options?)` builds a `Request` the way telegram would POST it to a
170
- webhook handler (`@yaebal/core`'s `webhookCallback`, or `@yaebal/web`'s `webhook`):
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`
171
283
 
172
284
  ```ts
173
- import { webhookRequest } from "@yaebal/test";
174
- import { webhookCallback } from "@yaebal/core";
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
+ ```
175
288
 
176
- const handler = webhookCallback(bot, { secretToken: "s3cret" });
177
- const res = await handler(webhookRequest(messageUpdate({ text: "hi" }), { secretToken: "s3cret" }));
178
- assert.equal(res.status, 200);
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
299
+
300
+ await env.advanceTime(60 * 60 * 1000); // fires it instantly
179
301
  ```
180
302
 
181
- `collectUpdates()` gives you a minimal `UpdateSink` (the `{ handleUpdate }` shape webhooks and
182
- runners expect) that just records what it receives handy when you don't need a full `Bot`:
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`:
183
310
 
184
311
  ```ts
185
- import { collectUpdates, messageUpdate } from "@yaebal/test";
312
+ import { installTestClock } from "@yaebal/test";
186
313
 
187
- const { sink, updates } = collectUpdates();
188
- await sink.handleUpdate(messageUpdate({ text: "hi" }));
189
- assert.equal(updates.length, 1);
314
+ const clock = installTestClock(1_700_000_000_000);
315
+ setInterval(() => {/* */}, 1000);
316
+ await clock.advance(3500); // fires 3 times
317
+ clock.restore();
190
318
  ```
191
319
 
192
- `withFetch(handler, fn)` stubs `globalThis.fetch` for the duration of `fn`, restoring the
193
- original afterwards even if `fn` throws — for code that proxies uploads or downloads:
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:
194
324
 
195
325
  ```ts
196
- import { withFetch } from "@yaebal/test";
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
+ ```
197
337
 
198
- await withFetch(
199
- async (url) => new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/jpeg" } }),
200
- async () => {
201
- const res = await panelHandler(request);
202
- assert.equal(res.status, 200);
203
- },
204
- );
338
+ ```ts
339
+ const env = createTestEnv(bot, { packs: [myPluginTestPack()] });
205
340
  ```
206
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
346
+
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" } },
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);
377
+ ```
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
+
207
384
  ## api reference
208
385
 
209
- | export | signature | description |
210
- |:---------------------------|:----------------------------------------------------------------|:-----------------------------------------------------------------------------|
211
- | `mockApi` | `(options?: MockApiOptions) => MockApi` | fake `Api` recording every call, with working hooks and configurable results |
212
- | `createUpdate` | `(partial?: Partial<Update>) => Update` | build an update, filling in a fresh `update_id` |
213
- | `messageUpdate` | `(options?: MessageUpdateOptions) => Update` | build a `message` update |
214
- | `editedMessageUpdate` | `(options?: MessageUpdateOptions) => Update` | build an `edited_message` update |
215
- | `channelPostUpdate` | `(options?: MessageUpdateOptions) => Update` | build a `channel_post` update |
216
- | `editedChannelPostUpdate` | `(options?: MessageUpdateOptions) => Update` | build an `edited_channel_post` update |
217
- | `callbackUpdate` | `(options?: CallbackUpdateOptions) => Update` | build a `callback_query` update |
218
- | `inlineQueryUpdate` | `(options?: InlineQueryUpdateOptions) => Update` | build an `inline_query` update |
219
- | `chosenInlineResultUpdate` | `(options?: ChosenInlineResultUpdateOptions) => Update` | build a `chosen_inline_result` update |
220
- | `shippingQueryUpdate` | `(options?: ShippingQueryUpdateOptions) => Update` | build a `shipping_query` update |
221
- | `preCheckoutQueryUpdate` | `(options?: PreCheckoutQueryUpdateOptions) => Update` | build a `pre_checkout_query` update |
222
- | `pollUpdate` | `(options?: PollUpdateOptions) => Update` | build a `poll` update |
223
- | `pollAnswerUpdate` | `(options?: PollAnswerUpdateOptions) => Update` | build a `poll_answer` update |
224
- | `myChatMemberUpdate` | `(options?: ChatMemberUpdateOptions) => Update` | build a `my_chat_member` update |
225
- | `chatMemberUpdate` | `(options?: ChatMemberUpdateOptions) => Update` | build a `chat_member` update |
226
- | `chatJoinRequestUpdate` | `(options?: ChatJoinRequestUpdateOptions) => Update` | build a `chat_join_request` update |
227
- | `createContext` | `(update, api?, updateType?) => Context` | wrap an update in a core `Context` |
228
- | `messageContext` | `(options?: MessageUpdateOptions, api?: Api) => Context` | build a `message` update and wrap it in one call |
229
- | `callbackContext` | `(options?: CallbackUpdateOptions, api?: Api) => Context` | build a `callback_query` update and wrap it in one call |
230
- | `runMiddleware` | `(composer: Composer<C>, ctx: C) => Promise<void>` | run a composer's middleware against a context |
231
- | `detectUpdateType` | `(update: Update) => UpdateName` | infer the payload key; defaults to `"message"` |
232
- | `findButton` | `(markup, match: string \| RegExp) => FoundButton \| undefined` | find an inline keyboard button by text |
233
- | `collectUpdates` | `() => UpdateCollector` | a minimal `UpdateSink` that records what it receives |
234
- | `webhookRequest` | `(update, options?: WebhookRequestOptions) => Request` | build a webhook POST request carrying an update |
235
- | `withFetch` | `(handler: typeof fetch, fn) => Promise<T>` | stub `globalThis.fetch` for `fn`, restoring it after |
236
-
237
- ### interfaces
238
-
239
- | type | fields |
240
- |:------------------------|:-------------------------------------------------------------------------------------------------------------------------|
241
- | `RecordedCall` | `method: string` · `params: Record<string, unknown> \| undefined` |
242
- | `MockApiOptions` | `results?: Record<string, unknown \| Error \| ((params, attempt: number) => unknown)>` |
243
- | `MockApi` | `api: Api` · `calls: RecordedCall[]` · `hooks` · `lastCall` · `callsTo` · `setResult` · `reset` |
244
- | `MessageUpdateOptions` | `text?: string` · `chatId?: number` · `fromId?: number` · `chatType?: "private" \| "group" \| "supergroup" \| "channel"` |
245
- | `CallbackUpdateOptions` | `data?: string` · `chatId?: number` · `fromId?: number` |
246
- | `FoundButton` | the button's own fields, plus `row: number` · `col: number` |
247
- | `UpdateCollector` | `sink: UpdateSink` · `updates: Update[]` |
248
- | `WebhookRequestOptions` | `url?: string` · `method?: string` · `secretToken?: string` · `headers?: Record<string, string>` |
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`.
249
422
 
250
423
  ---
251
424