@xbibzlibrary/telebibz 0.4.3 → 0.4.5

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 (71) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.id.md +20 -3
  3. package/README.md +20 -3
  4. package/README.zh-CN.md +20 -3
  5. package/dist/src/api/client.d.ts +28 -0
  6. package/dist/src/api/client.d.ts.map +1 -1
  7. package/dist/src/api/client.js +28 -0
  8. package/dist/src/api/client.js.map +1 -1
  9. package/dist/src/api/transport.d.ts +14 -0
  10. package/dist/src/api/transport.d.ts.map +1 -1
  11. package/dist/src/api/transport.js +67 -2
  12. package/dist/src/api/transport.js.map +1 -1
  13. package/dist/src/api/types.d.ts +25 -0
  14. package/dist/src/api/types.d.ts.map +1 -1
  15. package/dist/src/context/context.d.ts +13 -3
  16. package/dist/src/context/context.d.ts.map +1 -1
  17. package/dist/src/context/context.js +15 -0
  18. package/dist/src/context/context.js.map +1 -1
  19. package/dist/src/core/bot.d.ts +12 -0
  20. package/dist/src/core/bot.d.ts.map +1 -1
  21. package/dist/src/core/bot.js +16 -0
  22. package/dist/src/core/bot.js.map +1 -1
  23. package/dist/src/index.d.ts +1 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +1 -0
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/testing.d.ts +6 -0
  28. package/dist/src/testing.d.ts.map +1 -1
  29. package/dist/src/testing.js +6 -0
  30. package/dist/src/testing.js.map +1 -1
  31. package/dist/src/utils/files.d.ts +45 -0
  32. package/dist/src/utils/files.d.ts.map +1 -0
  33. package/dist/src/utils/files.js +53 -0
  34. package/dist/src/utils/files.js.map +1 -0
  35. package/dist-cjs/src/api/client.js +28 -0
  36. package/dist-cjs/src/api/transport.js +67 -2
  37. package/dist-cjs/src/context/context.js +15 -0
  38. package/dist-cjs/src/core/bot.js +16 -0
  39. package/dist-cjs/src/index.js +1 -0
  40. package/dist-cjs/src/testing.js +6 -0
  41. package/dist-cjs/src/utils/files.js +58 -0
  42. package/docs/API.id.md +70 -0
  43. package/docs/API.md +70 -0
  44. package/docs/API.zh-CN.md +70 -0
  45. package/docs/COOKBOOK.id.md +321 -0
  46. package/docs/COOKBOOK.md +321 -0
  47. package/docs/COOKBOOK.zh-CN.md +321 -0
  48. package/docs/ERRORS.id.md +194 -0
  49. package/docs/ERRORS.md +194 -0
  50. package/docs/ERRORS.zh-CN.md +194 -0
  51. package/docs/FILES.id.md +243 -0
  52. package/docs/FILES.md +243 -0
  53. package/docs/FILES.zh-CN.md +243 -0
  54. package/docs/GETTING_STARTED.id.md +6 -2
  55. package/docs/GETTING_STARTED.md +6 -2
  56. package/docs/GETTING_STARTED.zh-CN.md +6 -2
  57. package/docs/MIGRATION_TELEGRAF.id.md +147 -0
  58. package/docs/MIGRATION_TELEGRAF.md +154 -0
  59. package/docs/MIGRATION_TELEGRAF.zh-CN.md +147 -0
  60. package/docs/README.md +38 -19
  61. package/docs/STORAGE.id.md +105 -0
  62. package/docs/STORAGE.md +105 -0
  63. package/docs/STORAGE.zh-CN.md +105 -0
  64. package/docs/TESTING.id.md +203 -0
  65. package/docs/TESTING.md +203 -0
  66. package/docs/TESTING.zh-CN.md +203 -0
  67. package/docs/WEBHOOK.id.md +212 -0
  68. package/docs/WEBHOOK.md +215 -0
  69. package/docs/WEBHOOK.zh-CN.md +212 -0
  70. package/examples/files.ts +35 -0
  71. package/package.json +1 -1
@@ -0,0 +1,105 @@
1
+ # 存储快速上手(简体中文)
2
+
3
+ telebibz 提供一个通用的 `Storage<K, V>` 接口和五个适配器。核心包**零运行时依赖**:Redis、SQL 和 Mongo 适配器只要求一个你已经拥有的小型 driver interface,由你自己选择驱动和版本。
4
+
5
+ 所有适配器共享同一契约 —— `get` / `set` / `delete` / `has` / `clear` / `keys()` / `entries()` —— 以及 **`update(key, updater, { ttlMs })`**,它按 key 串行化写入,因此对同一 key 的并发更新永远不会交错。TTL 通过 `{ ttlMs }` 按每次写入设置。
6
+
7
+ ## MemoryStorage(默认 —— 无需配置)
8
+
9
+ ```ts
10
+ import { Bot } from "@xbibzlibrary/telebibz";
11
+
12
+ const bot = new Bot({ token: process.env.TELEGRAM_BOT_TOKEN! });
13
+ // bot.session 默认就是 MemoryStorage<string, S>。
14
+ ```
15
+
16
+ ## JsonFileStorage(单文件持久化,依然零依赖)
17
+
18
+ ```ts
19
+ import { Bot, JsonFileStorage } from "@xbibzlibrary/telebibz";
20
+
21
+ const bot = new Bot({
22
+ token: process.env.TELEGRAM_BOT_TOKEN!,
23
+ session: new JsonFileStorage("state/sessions.json"),
24
+ });
25
+ ```
26
+
27
+ ## RedisStorage(自带客户端)
28
+
29
+ 适配器只需要每个 Redis 客户端都有的五个回调式方法 —— `node-redis` 可以直接使用:
30
+
31
+ ```ts
32
+ import { Bot, RedisStorage } from "@xbibzlibrary/telebibz";
33
+ import { createClient } from "redis"; // 由你选择驱动和版本
34
+
35
+ const redis = createClient({ url: process.env.REDIS_URL });
36
+ await redis.connect();
37
+
38
+ const bot = new Bot({
39
+ token: process.env.TELEGRAM_BOT_TOKEN!,
40
+ session: new RedisStorage(redis, "mybot:"), // 你的 key 前缀
41
+ });
42
+ // 按次写入的 TTL:await bot.session.set(key, value, { ttlMs: 24 * 60 * 60 * 1000 });
43
+ // (Redis PX 过期会自动应用。)
44
+ ```
45
+
46
+ ## SqlStorage(任意 SQL 数据库)
47
+
48
+ 在你的 SQL 库之上实现这五个方法的 driver;示例使用 `better-sqlite3`:
49
+
50
+ ```ts
51
+ import { Bot, SqlStorage } from "@xbibzlibrary/telebibz";
52
+ import Database from "better-sqlite3";
53
+
54
+ const db = new Database("state/bot.db");
55
+ db.exec("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)");
56
+
57
+ const storage = new SqlStorage({
58
+ async get(key) {
59
+ const row = db.prepare("SELECT value, expires_at FROM kv WHERE key = ?").get(key) as { value: string; expires_at: number | null } | undefined;
60
+ return row === undefined ? undefined : JSON.parse(row.value);
61
+ },
62
+ async set(key, value, expiresAt) {
63
+ db.prepare("INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at")
64
+ .run(key, JSON.stringify(value), expiresAt ?? null);
65
+ },
66
+ async delete(key) { return db.prepare("DELETE FROM kv WHERE key = ?").run(key).changes > 0; },
67
+ async has(key) { return db.prepare("SELECT 1 FROM kv WHERE key = ?").get(key) !== undefined; },
68
+ async clear() { db.prepare("DELETE FROM kv").run(); },
69
+ async entries() {
70
+ const rows = db.prepare("SELECT key, value, expires_at FROM kv").all() as Array<{ key: string; value: string; expires_at: number | null }>;
71
+ return rows.map((row) => [row.key, JSON.parse(row.value), row.expiresAt ?? undefined] as [string, unknown, number | undefined]);
72
+ },
73
+ });
74
+
75
+ const bot = new Bot({ token: process.env.TELEGRAM_BOT_TOKEN!, session: storage });
76
+ ```
77
+
78
+ ## MongoStorage(自带 collection)
79
+
80
+ 适配器直接对接标准 MongoDB collection 形状 —— 直接传入你的 collection:
81
+
82
+ ```ts
83
+ import { Bot, MongoStorage } from "@xbibzlibrary/telebibz";
84
+ import { MongoClient } from "mongodb";
85
+
86
+ const client = new MongoClient(process.env.MONGODB_URL!);
87
+ await client.connect();
88
+
89
+ const bot = new Bot({
90
+ token: process.env.TELEGRAM_BOT_TOKEN!,
91
+ session: new MongoStorage(client.db("mybot").collection("sessions")),
92
+ });
93
+ ```
94
+
95
+ ## 如何选择
96
+
97
+ | 适配器 | 适用场景 | 持久化 | 额外依赖 |
98
+ |---|---|---|---|
99
+ | `MemoryStorage` | 单进程 bot、测试 | 进程生命周期 | 无 |
100
+ | `JsonFileStorage` | 小型 bot、简单部署 | 磁盘文件 | 无 |
101
+ | `RedisStorage` | 多实例、共享状态 | Redis | 你的 Redis 客户端 |
102
+ | `SqlStorage` | 基于 SQL 的应用 | 任意 SQL 数据库 | 你的 SQL 驱动 |
103
+ | `MongoStorage` | 已有 Mongo 技术栈 | MongoDB | 你的 Mongo 驱动 |
104
+
105
+ 完整 API 签名:[API.zh-CN.md](API.zh-CN.md)。English: [STORAGE.md](STORAGE.md) · Bahasa Indonesia: [STORAGE.id.md](STORAGE.id.md)。
@@ -0,0 +1,203 @@
1
+ # Panduan testing (Bahasa Indonesia)
2
+
3
+ Uji bot telebibz sepenuhnya offline: palsukan transport, rakit update, asersikan panggilan keluar, dan jalankan conversation utuh end-to-end.
4
+
5
+ ## Daftar isi
6
+
7
+ 1. [Subpath testing](#1-subpath-testing)
8
+ 2. [MockTransport](#2-mocktransport)
9
+ 3. [Men-drive update ke dalam bot](#3-men-drive-update-ke-dalam-bot)
10
+ 4. [Asersi panggilan keluar](#4-asersi-panggilan-keluar)
11
+ 5. [Menguji wizard end-to-end](#5-menguji-wizard-end-to-end)
12
+ 6. [Menguji unduhan file](#6-menguji-unduhan-file)
13
+ 7. [Menguji webhook](#7-menguji-webhook)
14
+ 8. [Menguji jalur error](#8-menguji-jalur-error)
15
+ 9. [Pola Vitest / Jest](#9-pola-vitest--jest)
16
+
17
+ ## 1. Subpath testing
18
+
19
+ Semuanya tersedia dari `@xbibzlibrary/telebibz/testing`:
20
+
21
+ ```ts
22
+ import { MockTransport, createTestBot, createMockUpdate, createMockCallbackUpdate, createMockContext } from "@xbibzlibrary/telebibz/testing";
23
+ ```
24
+
25
+ - `createTestBot()` → `{ bot, transport }` — sebuah `Bot` yang terhubung ke `MockTransport`, branding dimatikan.
26
+ - `createMockUpdate(overrides?)` → update pesan teks `/start` sederhana.
27
+ - `createMockCallbackUpdate(overrides?)` → update callback-query.
28
+ - `createMockContext(bot, update?)` → sebuah `Context` tanpa me-routing apa pun.
29
+
30
+ ## 2. MockTransport
31
+
32
+ `MockTransport` merekam setiap request keluar dan menjawab dari response yang dikonfigurasi:
33
+
34
+ ```ts
35
+ const { bot, transport } = createTestBot();
36
+
37
+ transport.respond("getMe", { ok: true, result: { id: 99, is_bot: true, first_name: "TestBot", username: "test_bot" } });
38
+ transport.respond("sendMessage", { ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } });
39
+ transport.respond("getFile", (payload) => ({ // jawaban dinamis
40
+ ok: true,
41
+ result: { file_id: (payload as { file_id: string }).file_id, file_unique_id: "u", file_path: "documents/a.pdf" },
42
+ }));
43
+ ```
44
+
45
+ Method yang tidak dikonfigurasi menjawab `{ ok: true, result: true }` — cukup untuk panggilan yang hasilnya Anda abaikan.
46
+
47
+ Ia juga mengimplementasikan member download:
48
+
49
+ ```ts
50
+ transport.downloadBytes = new TextEncoder().encode("file-content");
51
+ transport.downloads; // setiap file_path yang diberikan ke download(), berurutan
52
+ transport.fileUrl("a.pdf"); // "mock://files/a.pdf"
53
+ ```
54
+
55
+ ## 3. Men-drive update ke dalam bot
56
+
57
+ Daftarkan handler, lalu kirim update — tanpa jaringan, tanpa token:
58
+
59
+ ```ts
60
+ bot.on("message", async (ctx) => { await ctx.reply("hi"); });
61
+ await bot.init(); // tepat satu getMe, seperti produksi
62
+
63
+ await bot.handleUpdate(createMockUpdate({ message: { ...createMockUpdate().message!, text: "hello" } }));
64
+ await bot.handleUpdates([updateA, updateB, updateC]); // satu batch penuh, paralel antar chat
65
+ ```
66
+
67
+ Untuk mensimulasikan beberapa chat, variasikan chat id:
68
+
69
+ ```ts
70
+ function textUpdate(updateId: number, chatId: number, text: string) {
71
+ const base = createMockUpdate();
72
+ return { ...base, update_id: updateId, message: { ...base.message!, chat: { id: chatId, type: "private" as const }, text } };
73
+ }
74
+ ```
75
+
76
+ ## 4. Asersi panggilan keluar
77
+
78
+ Setiap request tercatat di `transport.calls` lengkap dengan method dan payload-nya:
79
+
80
+ ```ts
81
+ const replies = transport.calls
82
+ .filter((call) => call.method === "sendMessage")
83
+ .map((call) => (call.payload as { text?: string }).text);
84
+
85
+ expect(replies).toEqual(["Siapa nama Anda?", "Berapa usia Anda?"]);
86
+
87
+ // Keyboard callback:
88
+ const markup = (transport.calls.at(-1)?.payload as { reply_markup?: { inline_keyboard: unknown[][] } }).reply_markup;
89
+ expect(markup?.inline_keyboard).toHaveLength(2);
90
+ ```
91
+
92
+ ## 5. Menguji wizard end-to-end
93
+
94
+ Berikan conversation satu pesan demi satu pesan dan asersikan balasan yang akan dilihat user:
95
+
96
+ ```ts
97
+ import { Bot, Wizard } from "@xbibzlibrary/telebibz";
98
+
99
+ const wizard = new Wizard()
100
+ .step({ id: "ask", run: async (flow) => { flow.next(); await flow.ctx.reply("Siapa nama Anda?"); } })
101
+ .step({ id: "save", run: async (flow) => { await flow.ctx.reply(`Halo, ${flow.ctx.message?.text}!`); } });
102
+ bot.useWizard(wizard);
103
+ bot.command("start", async (ctx) => { await wizard.run(ctx); });
104
+
105
+ await bot.handleUpdate(textUpdate(1, 7, "/start"));
106
+ await bot.handleUpdate(textUpdate(2, 7, "Dewi"));
107
+
108
+ expect(sentTexts()).toEqual(["Siapa nama Anda?", "Halo, Dewi!"]);
109
+ ```
110
+
111
+ Dua chat, dua wizard independen — update yang berselang-seling tetap terisolasi karena update dari chat yang sama diserialisasi:
112
+
113
+ ```ts
114
+ await bot.handleUpdate(textUpdate(1, 7, "/start"));
115
+ await bot.handleUpdate(textUpdate(2, 8, "/start"));
116
+ await bot.handleUpdate(textUpdate(3, 7, "Dewi"));
117
+ await bot.handleUpdate(textUpdate(4, 8, "Budi"));
118
+ expect(sentTexts()).toEqual(["Siapa nama Anda?", "Siapa nama Anda?", "Halo, Dewi!", "Halo, Budi!"]);
119
+ ```
120
+
121
+ ## 6. Menguji unduhan file
122
+
123
+ ```ts
124
+ transport.respond("getFile", { ok: true, result: { file_id: "F1", file_unique_id: "U1", file_path: "photos/pic.jpg" } });
125
+ transport.downloadBytes = new TextEncoder().encode("jpeg-bytes");
126
+
127
+ const file = await bot.downloadFile("F1", { destination: tmpFile });
128
+ expect(file.fileName).toBe("pic.jpg");
129
+ expect(transport.downloads).toEqual(["photos/pic.jpg"]);
130
+ expect(await readFile(tmpFile, "utf8")).toBe("jpeg-bytes");
131
+ ```
132
+
133
+ ## 7. Menguji webhook
134
+
135
+ `createWebhookHandler` menerima `Request` Web sungguhan:
136
+
137
+ ```ts
138
+ const handler = createWebhookHandler(bot, { secretToken: "s3cret" });
139
+
140
+ const response = await handler(new Request("https://example.com/telegram", {
141
+ method: "POST",
142
+ headers: { "content-type": "application/json", "x-telegram-bot-api-secret-token": "s3cret" },
143
+ body: JSON.stringify(createMockUpdate()),
144
+ }));
145
+ expect(response.status).toBe(200);
146
+
147
+ // Secret salah → ditolak sebelum handler mana pun berjalan
148
+ const rejected = await handler(new Request("https://example.com/telegram", {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json", "x-telegram-bot-api-secret-token": "salah" },
151
+ body: JSON.stringify(createMockUpdate()),
152
+ }));
153
+ expect(rejected.status).toBe(401);
154
+ ```
155
+
156
+ ## 8. Menguji jalur error
157
+
158
+ ```ts
159
+ // Telegram menolak panggilan
160
+ transport.respond("sendMessage", { ok: false, error_code: 400, description: "Bad Request: chat not found" });
161
+ await expect(ctx.reply("hi")).rejects.toThrow(/chat not found/);
162
+
163
+ // Handler melempar — asersikan error boundary melihatnya
164
+ const errors: unknown[] = [];
165
+ bot.events.on("update:error", ({ error }) => errors.push(error));
166
+ bot.onText("boom", async () => { throw new Error("boom"); });
167
+ await bot.handleUpdate(textUpdate(1, 7, "boom"));
168
+ expect(errors).toHaveLength(1);
169
+
170
+ // handlerTimeout: update yang menggantung melempar UpdateTimeoutError sementara handler tetap berjalan
171
+ const slow = new Bot({ token: "123456:TEST", transport, handlerTimeout: 50, branding: false, logger: { level: "silent" } });
172
+ let finished = false;
173
+ slow.on("message", async () => { await sleep(200); finished = true; });
174
+ await expect(slow.handleUpdate(createMockUpdate())).rejects.toBeInstanceOf(UpdateTimeoutError);
175
+ // nanti:
176
+ expect(finished).toBe(true);
177
+ ```
178
+
179
+ ## 9. Pola Vitest / Jest
180
+
181
+ **Senyapkan logger di test** — `logger: { level: "silent" }` tidak memancarkan apa pun (diuji regresi):
182
+
183
+ ```ts
184
+ const bot = new Bot({ token: "123456:TEST", transport, branding: false, logger: { level: "silent" } });
185
+ ```
186
+
187
+ **Transport segar per test** — jangan pernah berbagi state antar kasus:
188
+
189
+ ```ts
190
+ beforeEach(() => { ({ bot, transport } = createTestBot()); });
191
+ ```
192
+
193
+ **Mempercepat waktu** — untuk test `Scheduler`/TTL pakai fake timer atau interval kecil (`1ms`); parser cron murni dan bisa diuji tanpa timer:
194
+
195
+ ```ts
196
+ import { nextCronOccurrence, parseCronExpression } from "@xbibzlibrary/telebibz";
197
+ expect(parseCronExpression("*/15 * * * *")).toBeDefined();
198
+ expect(nextCronOccurrence("0 9 * * 1", new Date("2026-08-29T00:00:00Z")).toISOString()).toContain("T09:00");
199
+ ```
200
+
201
+ **Jalankan suite**: `npm test` — test E2E yang membutuhkan token asli otomatis di-skip ketika `TELEGRAM_BOT_TOKEN` tidak ada.
202
+
203
+ English: [TESTING.md](TESTING.md) · 简体中文: [TESTING.zh-CN.md](TESTING.zh-CN.md)
@@ -0,0 +1,203 @@
1
+ # Testing guide (English)
2
+
3
+ Test telebibz bots fully offline: fake the transport, craft updates, assert outgoing calls, and drive whole conversations end to end.
4
+
5
+ ## Contents
6
+
7
+ 1. [The testing subpath](#1-the-testing-subpath)
8
+ 2. [MockTransport](#2-mocktransport)
9
+ 3. [Driving updates through the bot](#3-driving-updates-through-the-bot)
10
+ 4. [Asserting outgoing calls](#4-asserting-outgoing-calls)
11
+ 5. [Testing wizards end to end](#5-testing-wizards-end-to-end)
12
+ 6. [Testing file downloads](#6-testing-file-downloads)
13
+ 7. [Testing webhooks](#7-testing-webhooks)
14
+ 8. [Testing error paths](#8-testing-error-paths)
15
+ 9. [Vitest / Jest patterns](#9-vitest--jest-patterns)
16
+
17
+ ## 1. The testing subpath
18
+
19
+ Everything ships from `@xbibzlibrary/telebibz/testing`:
20
+
21
+ ```ts
22
+ import { MockTransport, createTestBot, createMockUpdate, createMockCallbackUpdate, createMockContext } from "@xbibzlibrary/telebibz/testing";
23
+ ```
24
+
25
+ - `createTestBot()` → `{ bot, transport }` — a `Bot` wired to a `MockTransport`, branding off.
26
+ - `createMockUpdate(overrides?)` → a plain text `/start` message update.
27
+ - `createMockCallbackUpdate(overrides?)` → a callback-query update.
28
+ - `createMockContext(bot, update?)` → a `Context` without routing anything.
29
+
30
+ ## 2. MockTransport
31
+
32
+ `MockTransport` records every outgoing request and answers from configured responses:
33
+
34
+ ```ts
35
+ const { bot, transport } = createTestBot();
36
+
37
+ transport.respond("getMe", { ok: true, result: { id: 99, is_bot: true, first_name: "TestBot", username: "test_bot" } });
38
+ transport.respond("sendMessage", { ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } });
39
+ transport.respond("getFile", (payload) => ({ // dynamic answers
40
+ ok: true,
41
+ result: { file_id: (payload as { file_id: string }).file_id, file_unique_id: "u", file_path: "documents/a.pdf" },
42
+ }));
43
+ ```
44
+
45
+ Unconfigured methods answer `{ ok: true, result: true }` — enough for calls whose result you ignore.
46
+
47
+ It also implements the download members:
48
+
49
+ ```ts
50
+ transport.downloadBytes = new TextEncoder().encode("file-content");
51
+ transport.downloads; // every file_path passed to download(), in order
52
+ transport.fileUrl("a.pdf"); // "mock://files/a.pdf"
53
+ ```
54
+
55
+ ## 3. Driving updates through the bot
56
+
57
+ Register handlers, then feed updates — no network, no token:
58
+
59
+ ```ts
60
+ bot.on("message", async (ctx) => { await ctx.reply("hi"); });
61
+ await bot.init(); // exactly one getMe, like production
62
+
63
+ await bot.handleUpdate(createMockUpdate({ message: { ...createMockUpdate().message!, text: "hello" } }));
64
+ await bot.handleUpdates([updateA, updateB, updateC]); // whole batch, parallel across chats
65
+ ```
66
+
67
+ To simulate several chats, vary the chat id:
68
+
69
+ ```ts
70
+ function textUpdate(updateId: number, chatId: number, text: string) {
71
+ const base = createMockUpdate();
72
+ return { ...base, update_id: updateId, message: { ...base.message!, chat: { id: chatId, type: "private" as const }, text } };
73
+ }
74
+ ```
75
+
76
+ ## 4. Asserting outgoing calls
77
+
78
+ Every request is recorded in `transport.calls` with its method and payload:
79
+
80
+ ```ts
81
+ const replies = transport.calls
82
+ .filter((call) => call.method === "sendMessage")
83
+ .map((call) => (call.payload as { text?: string }).text);
84
+
85
+ expect(replies).toEqual(["What is your name?", "How old are you?"]);
86
+
87
+ // Callback keyboards:
88
+ const markup = (transport.calls.at(-1)?.payload as { reply_markup?: { inline_keyboard: unknown[][] } }).reply_markup;
89
+ expect(markup?.inline_keyboard).toHaveLength(2);
90
+ ```
91
+
92
+ ## 5. Testing wizards end to end
93
+
94
+ Feed the conversation one message at a time and assert the replies the user would see:
95
+
96
+ ```ts
97
+ import { Bot, Wizard } from "@xbibzlibrary/telebibz";
98
+
99
+ const wizard = new Wizard()
100
+ .step({ id: "ask", run: async (flow) => { flow.next(); await flow.ctx.reply("Name?"); } })
101
+ .step({ id: "save", run: async (flow) => { await flow.ctx.reply(`Hi ${flow.ctx.message?.text}!`); } });
102
+ bot.useWizard(wizard);
103
+ bot.command("start", async (ctx) => { await wizard.run(ctx); });
104
+
105
+ await bot.handleUpdate(textUpdate(1, 7, "/start"));
106
+ await bot.handleUpdate(textUpdate(2, 7, "Alice"));
107
+
108
+ expect(sentTexts()).toEqual(["Name?", "Hi Alice!"]);
109
+ ```
110
+
111
+ Two chats, two independent wizards — interleaved updates stay isolated because same-chat updates serialize:
112
+
113
+ ```ts
114
+ await bot.handleUpdate(textUpdate(1, 7, "/start"));
115
+ await bot.handleUpdate(textUpdate(2, 8, "/start"));
116
+ await bot.handleUpdate(textUpdate(3, 7, "Alice"));
117
+ await bot.handleUpdate(textUpdate(4, 8, "Bob"));
118
+ expect(sentTexts()).toEqual(["Name?", "Name?", "Hi Alice!", "Hi Bob!"]);
119
+ ```
120
+
121
+ ## 6. Testing file downloads
122
+
123
+ ```ts
124
+ transport.respond("getFile", { ok: true, result: { file_id: "F1", file_unique_id: "U1", file_path: "photos/pic.jpg" } });
125
+ transport.downloadBytes = new TextEncoder().encode("jpeg-bytes");
126
+
127
+ const file = await bot.downloadFile("F1", { destination: tmpFile });
128
+ expect(file.fileName).toBe("pic.jpg");
129
+ expect(transport.downloads).toEqual(["photos/pic.jpg"]);
130
+ expect(await readFile(tmpFile, "utf8")).toBe("jpeg-bytes");
131
+ ```
132
+
133
+ ## 7. Testing webhooks
134
+
135
+ `createWebhookHandler` accepts a real Web `Request`:
136
+
137
+ ```ts
138
+ const handler = createWebhookHandler(bot, { secretToken: "s3cret" });
139
+
140
+ const response = await handler(new Request("https://example.com/telegram", {
141
+ method: "POST",
142
+ headers: { "content-type": "application/json", "x-telegram-bot-api-secret-token": "s3cret" },
143
+ body: JSON.stringify(createMockUpdate()),
144
+ }));
145
+ expect(response.status).toBe(200);
146
+
147
+ // Wrong secret → rejected before any handler runs
148
+ const rejected = await handler(new Request("https://example.com/telegram", {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json", "x-telegram-bot-api-secret-token": "wrong" },
151
+ body: JSON.stringify(createMockUpdate()),
152
+ }));
153
+ expect(rejected.status).toBe(401);
154
+ ```
155
+
156
+ ## 8. Testing error paths
157
+
158
+ ```ts
159
+ // Telegram rejects the call
160
+ transport.respond("sendMessage", { ok: false, error_code: 400, description: "Bad Request: chat not found" });
161
+ await expect(ctx.reply("hi")).rejects.toThrow(/chat not found/);
162
+
163
+ // A handler throws — assert the error boundary saw it
164
+ const errors: unknown[] = [];
165
+ bot.events.on("update:error", ({ error }) => errors.push(error));
166
+ bot.onText("boom", async () => { throw new Error("boom"); });
167
+ await bot.handleUpdate(textUpdate(1, 7, "boom"));
168
+ expect(errors).toHaveLength(1);
169
+
170
+ // handlerTimeout: hung updates reject with UpdateTimeoutError while the handler keeps running
171
+ const slow = new Bot({ token: "123456:TEST", transport, handlerTimeout: 50, branding: false, logger: { level: "silent" } });
172
+ let finished = false;
173
+ slow.on("message", async () => { await sleep(200); finished = true; });
174
+ await expect(slow.handleUpdate(createMockUpdate())).rejects.toBeInstanceOf(UpdateTimeoutError);
175
+ // later:
176
+ expect(finished).toBe(true);
177
+ ```
178
+
179
+ ## 9. Vitest / Jest patterns
180
+
181
+ **Silence the logger in tests** — `logger: { level: "silent" }` emits nothing (regression-tested):
182
+
183
+ ```ts
184
+ const bot = new Bot({ token: "123456:TEST", transport, branding: false, logger: { level: "silent" } });
185
+ ```
186
+
187
+ **Fresh transport per test** — never share state between cases:
188
+
189
+ ```ts
190
+ beforeEach(() => { ({ bot, transport } = createTestBot()); });
191
+ ```
192
+
193
+ **Fast-forwarding time** — for `Scheduler`/TTL tests use fake timers or small intervals (`1ms`); the cron parser is pure and testable without timers:
194
+
195
+ ```ts
196
+ import { nextCronOccurrence, parseCronExpression } from "@xbibzlibrary/telebibz";
197
+ expect(parseCronExpression("*/15 * * * *")).toBeDefined();
198
+ expect(nextCronOccurrence("0 9 * * 1", new Date("2026-08-29T00:00:00Z")).toISOString()).toContain("T09:00");
199
+ ```
200
+
201
+ **Run the suite**: `npm test` — E2E tests requiring a real token are skipped automatically when `TELEGRAM_BOT_TOKEN` is absent.
202
+
203
+ Bahasa Indonesia: [TESTING.id.md](TESTING.id.md) · 简体中文: [TESTING.zh-CN.md](TESTING.zh-CN.md)