@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.
- package/README.md +333 -160
- 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 -205
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +19 -391
- 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 -630
- 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 -213
- package/lib/index.test.js.map +0 -1
- package/src/index.test.ts +0 -320
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# @yaebal/test
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
##
|
|
22
|
+
## quick start
|
|
17
23
|
|
|
18
24
|
```ts
|
|
19
|
-
import {
|
|
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
|
|
24
|
-
const
|
|
25
|
-
|
|
32
|
+
const env = createTestEnv(bot);
|
|
33
|
+
const linia = env.createUser({ firstName: "Linia" });
|
|
34
|
+
|
|
35
|
+
await linia.sendCommand("start");
|
|
26
36
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
const linia = env.createUser({ firstName: "Linia", username: "linia" });
|
|
74
|
+
```
|
|
47
75
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
makes the call throw, e.g. `TelegramError`), or a function of `(params, attempt)`:
|
|
130
|
+
### buttons, reactions, joins
|
|
74
131
|
|
|
75
132
|
```ts
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
await linia.sendInlineQuery("cats", group); // chat_type derived from group.type
|
|
158
|
+
await linia.chooseInlineResult("result-1", "cats");
|
|
99
159
|
|
|
100
|
-
|
|
160
|
+
await linia.sendPreCheckoutQuery({ currency: "XTR", total_amount: 100, invoice_payload: "sub" });
|
|
161
|
+
await linia.sendShippingQuery({ invoice_payload: "physical_item" });
|
|
101
162
|
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
##
|
|
190
|
+
## chats
|
|
116
191
|
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
137
|
-
`detectUpdateType` infers which payload key an update carries.
|
|
203
|
+
## inspecting what the bot did
|
|
138
204
|
|
|
139
205
|
```ts
|
|
140
|
-
|
|
206
|
+
await linia.sendMessage("hi");
|
|
141
207
|
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
263
|
+
### `apiError(code, description, parameters?)` — simulate a real Telegram failure
|
|
168
264
|
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
`
|
|
182
|
-
|
|
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 {
|
|
312
|
+
import { installTestClock } from "@yaebal/test";
|
|
186
313
|
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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 {
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
| `
|
|
212
|
-
| `
|
|
213
|
-
| `
|
|
214
|
-
| `
|
|
215
|
-
| `
|
|
216
|
-
| `
|
|
217
|
-
| `
|
|
218
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
222
|
-
| `
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
225
|
-
| `
|
|
226
|
-
| `
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|