@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.
- package/README.md +400 -15
- package/lib/api.d.ts +83 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +186 -0
- package/lib/api.js.map +1 -0
- package/lib/api.test.d.ts +2 -0
- package/lib/api.test.d.ts.map +1 -0
- package/lib/api.test.js +131 -0
- package/lib/api.test.js.map +1 -0
- package/lib/bot-messages.d.ts +42 -0
- package/lib/bot-messages.d.ts.map +1 -0
- package/lib/bot-messages.js +72 -0
- package/lib/bot-messages.js.map +1 -0
- package/lib/chat-actor.d.ts +45 -0
- package/lib/chat-actor.d.ts.map +1 -0
- package/lib/chat-actor.js +72 -0
- package/lib/chat-actor.js.map +1 -0
- package/lib/clock.d.ts +22 -0
- package/lib/clock.d.ts.map +1 -0
- package/lib/clock.js +72 -0
- package/lib/clock.js.map +1 -0
- package/lib/clock.test.d.ts +2 -0
- package/lib/clock.test.d.ts.map +1 -0
- package/lib/clock.test.js +69 -0
- package/lib/clock.test.js.map +1 -0
- package/lib/env.d.ts +100 -0
- package/lib/env.d.ts.map +1 -0
- package/lib/env.js +164 -0
- package/lib/env.js.map +1 -0
- package/lib/env.test.d.ts +2 -0
- package/lib/env.test.d.ts.map +1 -0
- package/lib/env.test.js +302 -0
- package/lib/env.test.js.map +1 -0
- package/lib/fetch.d.ts +3 -0
- package/lib/fetch.d.ts.map +1 -0
- package/lib/fetch.js +12 -0
- package/lib/fetch.js.map +1 -0
- package/lib/index.d.ts +19 -52
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +19 -115
- package/lib/index.js.map +1 -1
- package/lib/internal.d.ts +25 -0
- package/lib/internal.d.ts.map +1 -0
- package/lib/internal.js +19 -0
- package/lib/internal.js.map +1 -0
- package/lib/keyboard.d.ts +15 -0
- package/lib/keyboard.d.ts.map +1 -0
- package/lib/keyboard.js +25 -0
- package/lib/keyboard.js.map +1 -0
- package/lib/keyboard.test.d.ts +2 -0
- package/lib/keyboard.test.d.ts.map +1 -0
- package/lib/keyboard.test.js +31 -0
- package/lib/keyboard.test.js.map +1 -0
- package/lib/normalize.d.ts +10 -0
- package/lib/normalize.d.ts.map +1 -0
- package/lib/normalize.js +27 -0
- package/lib/normalize.js.map +1 -0
- package/lib/reactions.d.ts +3 -0
- package/lib/reactions.d.ts.map +1 -0
- package/lib/reactions.js +17 -0
- package/lib/reactions.js.map +1 -0
- package/lib/updates.d.ts +126 -0
- package/lib/updates.d.ts.map +1 -0
- package/lib/updates.js +200 -0
- package/lib/updates.js.map +1 -0
- package/lib/updates.test.d.ts +2 -0
- package/lib/updates.test.d.ts.map +1 -0
- package/lib/updates.test.js +72 -0
- package/lib/updates.test.js.map +1 -0
- package/lib/user-actor.d.ts +188 -0
- package/lib/user-actor.d.ts.map +1 -0
- package/lib/user-actor.js +465 -0
- package/lib/user-actor.js.map +1 -0
- package/lib/webhook.d.ts +18 -0
- package/lib/webhook.d.ts.map +1 -0
- package/lib/webhook.js +25 -0
- package/lib/webhook.js.map +1 -0
- package/lib/webhook.test.d.ts +2 -0
- package/lib/webhook.test.d.ts.map +1 -0
- package/lib/webhook.test.js +36 -0
- package/lib/webhook.test.js.map +1 -0
- package/package.json +7 -5
- package/src/api.test.ts +180 -0
- package/src/api.ts +289 -0
- package/src/bot-messages.ts +117 -0
- package/src/chat-actor.ts +101 -0
- package/src/clock.test.ts +80 -0
- package/src/clock.ts +118 -0
- package/src/env.test.ts +370 -0
- package/src/env.ts +235 -0
- package/src/fetch.ts +11 -0
- package/src/index.ts +79 -169
- package/src/internal.ts +43 -0
- package/src/keyboard.test.ts +35 -0
- package/src/keyboard.ts +38 -0
- package/src/normalize.ts +34 -0
- package/src/reactions.ts +18 -0
- package/src/updates.test.ts +107 -0
- package/src/updates.ts +354 -0
- package/src/user-actor.ts +702 -0
- package/src/webhook.test.ts +54 -0
- package/src/webhook.ts +48 -0
- package/lib/index.test.d.ts +0 -2
- package/lib/index.test.d.ts.map +0 -1
- package/lib/index.test.js +0 -66
- package/lib/index.test.js.map +0 -1
- package/src/index.test.ts +0 -101
package/README.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# @yaebal/test
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
22
|
+
## quick start
|
|
12
23
|
|
|
13
24
|
```ts
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
|
19
|
-
const
|
|
20
|
-
|
|
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
|
-
|
|
203
|
+
## inspecting what the bot did
|
|
23
204
|
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/api.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|