@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
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { installTestClock } from "./clock.js";
4
+
5
+ test("installTestClock overrides Date.now and advances on demand", async () => {
6
+ const clock = installTestClock(1_700_000_000_000);
7
+ try {
8
+ assert.equal(Date.now(), 1_700_000_000_000);
9
+ await clock.advance(5000);
10
+ assert.equal(Date.now(), 1_700_000_005_000);
11
+ } finally {
12
+ clock.restore();
13
+ }
14
+ });
15
+
16
+ test("advance() fires a setTimeout whose deadline falls inside the window", async () => {
17
+ const clock = installTestClock();
18
+ try {
19
+ let fired = false;
20
+ setTimeout(
21
+ () => {
22
+ fired = true;
23
+ },
24
+ 60 * 60 * 1000,
25
+ );
26
+
27
+ await clock.advance(30 * 60 * 1000);
28
+ assert.equal(fired, false);
29
+
30
+ await clock.advance(30 * 60 * 1000);
31
+ assert.equal(fired, true);
32
+ } finally {
33
+ clock.restore();
34
+ }
35
+ });
36
+
37
+ test("advance() re-arms setInterval and may fire it multiple times", async () => {
38
+ const clock = installTestClock();
39
+ try {
40
+ let count = 0;
41
+ const id = setInterval(() => count++, 1000);
42
+
43
+ await clock.advance(3500);
44
+ assert.equal(count, 3);
45
+
46
+ clearInterval(id);
47
+ await clock.advance(10_000);
48
+ assert.equal(count, 3);
49
+ } finally {
50
+ clock.restore();
51
+ }
52
+ });
53
+
54
+ test("timers scheduled inside a firing callback are picked up by the same advance() call", async () => {
55
+ const clock = installTestClock();
56
+ try {
57
+ const seen: number[] = [];
58
+
59
+ setTimeout(() => {
60
+ seen.push(1);
61
+ setTimeout(() => seen.push(2), 100);
62
+ }, 100);
63
+
64
+ await clock.advance(500);
65
+ assert.deepEqual(seen, [1, 2]);
66
+ } finally {
67
+ clock.restore();
68
+ }
69
+ });
70
+
71
+ test("restore() puts back the real Date.now/setTimeout", () => {
72
+ const realNow = Date.now;
73
+ const realSetTimeout = setTimeout;
74
+
75
+ const clock = installTestClock();
76
+ clock.restore();
77
+
78
+ assert.equal(Date.now, realNow);
79
+ assert.equal(setTimeout, realSetTimeout);
80
+ });
package/src/clock.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * a virtual clock: overrides `Date.now`, `setTimeout`/`clearTimeout`, and
3
+ * `setInterval`/`clearInterval` so TTL / retry-delay / debounce code under test
4
+ * can be advanced instantly instead of waited on for real. installs globally
5
+ * for the lifetime of the clock; always `.restore()` when done (or let
6
+ * {@link TestEnv.shutdown} do it for you).
7
+ */
8
+ export interface TestClock {
9
+ /** the clock's current virtual time, in ms since epoch. */
10
+ now(): number;
11
+ /** advance the clock by `ms`, firing every timer whose deadline falls inside the window (in deadline order). intervals re-arm and may fire multiple times. */
12
+ advance(ms: number): Promise<void>;
13
+ /** uninstall — restores the real `Date.now`/`setTimeout`/`setInterval`. */
14
+ restore(): void;
15
+ }
16
+
17
+ interface Timer {
18
+ id: number;
19
+ due: number;
20
+ interval: number | undefined; // undefined = one-shot (setTimeout)
21
+ fn: (...args: unknown[]) => void;
22
+ args: unknown[];
23
+ cancelled: boolean;
24
+ }
25
+
26
+ /**
27
+ * install a virtual clock, overriding the global timer functions. `startAt`
28
+ * defaults to the real current time so unrelated `Date.now()` reads (e.g. log
29
+ * timestamps) stay plausible.
30
+ */
31
+ export function installTestClock(startAt: number = Date.now()): TestClock {
32
+ const realDateNow = Date.now;
33
+ const realSetTimeout = globalThis.setTimeout;
34
+ const realClearTimeout = globalThis.clearTimeout;
35
+ const realSetInterval = globalThis.setInterval;
36
+ const realClearInterval = globalThis.clearInterval;
37
+
38
+ let now = startAt;
39
+ let nextId = 1;
40
+ const timers = new Map<number, Timer>();
41
+
42
+ const schedule = (
43
+ fn: (...args: unknown[]) => void,
44
+ delay: number,
45
+ interval: number | undefined,
46
+ args: unknown[],
47
+ ): number => {
48
+ const id = nextId++;
49
+ timers.set(id, {
50
+ id,
51
+ due: now + Math.max(0, delay || 0),
52
+ interval,
53
+ fn,
54
+ args,
55
+ cancelled: false,
56
+ });
57
+ return id;
58
+ };
59
+
60
+ Date.now = () => now;
61
+
62
+ globalThis.setTimeout = ((fn: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) =>
63
+ schedule(fn, delay ?? 0, undefined, args)) as unknown as typeof setTimeout;
64
+
65
+ globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
66
+ if (typeof id === "number") timers.delete(id);
67
+ }) as unknown as typeof clearTimeout;
68
+
69
+ globalThis.setInterval = ((
70
+ fn: (...args: unknown[]) => void,
71
+ delay?: number,
72
+ ...args: unknown[]
73
+ ) => schedule(fn, delay ?? 0, delay ?? 0, args)) as unknown as typeof setInterval;
74
+
75
+ globalThis.clearInterval = ((id?: number | ReturnType<typeof setInterval>) => {
76
+ if (typeof id === "number") timers.delete(id);
77
+ }) as unknown as typeof clearInterval;
78
+
79
+ async function advance(ms: number): Promise<void> {
80
+ const target = now + ms;
81
+
82
+ for (;;) {
83
+ let next: Timer | undefined;
84
+
85
+ for (const timer of timers.values()) {
86
+ if (timer.due > target) continue;
87
+ if (!next || timer.due < next.due || (timer.due === next.due && timer.id < next.id)) {
88
+ next = timer;
89
+ }
90
+ }
91
+
92
+ if (!next) break;
93
+
94
+ now = next.due;
95
+
96
+ if (next.interval === undefined) {
97
+ timers.delete(next.id);
98
+ } else {
99
+ next.due = now + Math.max(1, next.interval);
100
+ }
101
+
102
+ await next.fn(...next.args);
103
+ }
104
+
105
+ now = target;
106
+ }
107
+
108
+ function restore(): void {
109
+ Date.now = realDateNow;
110
+ globalThis.setTimeout = realSetTimeout;
111
+ globalThis.clearTimeout = realClearTimeout;
112
+ globalThis.setInterval = realSetInterval;
113
+ globalThis.clearInterval = realClearInterval;
114
+ timers.clear();
115
+ }
116
+
117
+ return { now: () => now, advance, restore };
118
+ }
@@ -0,0 +1,370 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { bold, Composer, type Context, format } from "@yaebal/core";
4
+ import { apiError } from "./api.js";
5
+ import { createTestEnv } from "./env.js";
6
+
7
+ test("createUser().sendMessage dispatches a message update and records the bot's reply", async () => {
8
+ const bot = new Composer<Context>().on("message:text", (ctx) => ctx.reply("pong"));
9
+ const env = createTestEnv(bot);
10
+ const user = env.createUser({ firstName: "Linia" });
11
+
12
+ await user.sendMessage("ping");
13
+
14
+ assert.equal(env.apiCalls.length, 1);
15
+ assert.equal(env.lastApiCall()?.method, "sendMessage");
16
+ assert.equal(env.lastApiCall("sendMessage")?.params?.text, "pong");
17
+ assert.equal(env.lastApiCall()?.params?.chat_id, user.pmChat.id);
18
+ });
19
+
20
+ test("sendMessage(chat, text) sends into a group; .in(chat) scopes the same way", async () => {
21
+ const bot = new Composer<Context>().on("message:text", (ctx) => ctx.reply("hi group"));
22
+ const env = createTestEnv(bot);
23
+ const user = env.createUser();
24
+ const group = env.createChat({ type: "group", title: "devs" });
25
+
26
+ await user.sendMessage(group, "hello");
27
+ assert.equal(env.lastApiCall("sendMessage")?.params?.chat_id, group.id);
28
+
29
+ env.clearApiCalls();
30
+ await user.in(group).sendMessage("hello again");
31
+ assert.equal(env.lastApiCall("sendMessage")?.params?.chat_id, group.id);
32
+ });
33
+
34
+ test("sendMessage accepts a format() result — entities are auto-extracted (rich integration)", async () => {
35
+ let entityType: string | undefined;
36
+ const bot = new Composer<Context>().on("message:text", (ctx) => {
37
+ entityType = ctx.message?.entities?.[0]?.type;
38
+ });
39
+ const env = createTestEnv(bot);
40
+ const user = env.createUser();
41
+
42
+ await user.sendMessage(format`Check out ${bold("this")}`);
43
+ assert.equal(entityType, "bold");
44
+ });
45
+
46
+ test("sendCommand attaches a bot_command entity and parses args via Composer.command", async () => {
47
+ let captured: { command: string; args: string[] } | undefined;
48
+ const bot = new Composer<Context & { command: string; args: string[] }>().command(
49
+ "start",
50
+ (ctx) => {
51
+ captured = { command: ctx.command, args: ctx.args };
52
+ },
53
+ );
54
+ const env = createTestEnv(bot);
55
+ const user = env.createUser();
56
+
57
+ await user.sendCommand("start", "ref42");
58
+
59
+ assert.deepEqual(captured, { command: "start", args: ["ref42"] });
60
+ });
61
+
62
+ test("sendReply sets reply_to_message and targets the same chat", async () => {
63
+ const bot = new Composer<Context>();
64
+ const env = createTestEnv(bot);
65
+ const user = env.createUser();
66
+ const group = env.createChat({ type: "group" });
67
+
68
+ const original = await user.sendMessage(group, "hello");
69
+ const reply = await user.sendReply(original, "nice to meet you");
70
+
71
+ assert.equal(reply.reply_to_message?.message_id, original.message_id);
72
+ assert.equal(reply.chat.id, group.id);
73
+ });
74
+
75
+ test("media shortcuts attach a plausible attachment with auto file ids, caption entities, and spoiler", async () => {
76
+ const bot = new Composer<Context>();
77
+ const env = createTestEnv(bot);
78
+ const user = env.createUser();
79
+
80
+ const photo = await user.sendPhoto({ caption: format`nice ${bold("shot")}`, spoiler: true });
81
+ assert.ok(photo.photo?.length);
82
+ assert.ok(photo.photo?.[0]?.file_id);
83
+ assert.equal(photo.caption, "nice shot");
84
+ assert.equal(photo.caption_entities?.[0]?.type, "bold");
85
+ assert.equal(photo.has_media_spoiler, true);
86
+
87
+ const sticker = await user.sendSticker({ emoji: "🔥" });
88
+ assert.equal(sticker.sticker?.type, "regular");
89
+ assert.equal(sticker.sticker?.emoji, "🔥");
90
+ });
91
+
92
+ test("sendMediaGroup dispatches one message per item, all sharing media_group_id", async () => {
93
+ const groupIds: string[] = [];
94
+ const bot = new Composer<Context>().on("message", (ctx) => {
95
+ if (ctx.message?.media_group_id) groupIds.push(ctx.message.media_group_id);
96
+ });
97
+ const env = createTestEnv(bot);
98
+ const user = env.createUser();
99
+ const group = env.createChat({ type: "group" });
100
+
101
+ const [m1, m2] = await user.sendMediaGroup(group, [
102
+ { photo: [{ file_id: "f1", file_unique_id: "u1", width: 100, height: 100 }] },
103
+ { photo: [{ file_id: "f2", file_unique_id: "u2", width: 100, height: 100 }] },
104
+ ]);
105
+
106
+ assert.equal(groupIds.length, 2);
107
+ assert.equal(m1?.media_group_id, m2?.media_group_id);
108
+ });
109
+
110
+ test("click() dispatches a callback_query the bot's callbackQuery() handler sees", async () => {
111
+ let seenData: string | undefined;
112
+ const bot = new Composer<Context>().callbackQuery("vote:up", (ctx) => {
113
+ seenData = ctx.callbackQuery.data;
114
+ });
115
+ const env = createTestEnv(bot);
116
+ const user = env.createUser();
117
+
118
+ await user.click("vote:up");
119
+ assert.equal(seenData, "vote:up");
120
+ });
121
+
122
+ test("lastBotMessage() stays in sync with editMessageText/editMessageReplyMarkup, even on an earlier reference", async () => {
123
+ const bot = new Composer<Context>()
124
+ .on("message:text", (ctx) =>
125
+ ctx.send("Pick:", {
126
+ reply_markup: { inline_keyboard: [[{ text: "Next", callback_data: "next" }]] },
127
+ }),
128
+ )
129
+ .callbackQuery("next", async (ctx) => {
130
+ const msg = ctx.callbackQuery.message as { chat: { id: number }; message_id: number };
131
+ await ctx.api.call("editMessageText", {
132
+ chat_id: msg.chat.id,
133
+ message_id: msg.message_id,
134
+ text: "Done!",
135
+ });
136
+ await ctx.api.call("editMessageReplyMarkup", {
137
+ chat_id: msg.chat.id,
138
+ message_id: msg.message_id,
139
+ reply_markup: { inline_keyboard: [[{ text: "Restart", callback_data: "restart" }]] },
140
+ });
141
+ });
142
+
143
+ const env = createTestEnv(bot);
144
+ const user = env.createUser();
145
+
146
+ await user.sendMessage("hi");
147
+ const bubble = env.lastBotMessage();
148
+ assert.ok(bubble);
149
+ assert.ok(bubble.reply_markup);
150
+
151
+ await user.on(bubble).clickByText("Next");
152
+
153
+ assert.equal(env.lastBotMessage(), bubble); // same reference
154
+ assert.equal(bubble.text, "Done!");
155
+ assert.equal((bubble.reply_markup?.inline_keyboard as unknown[][])[0]?.length, 1);
156
+
157
+ await user.on(bubble).clickByText("Restart");
158
+ });
159
+
160
+ test("lastBotMessage() query filters: chat, withReplyMarkup, where", async () => {
161
+ const bot = new Composer<Context>().on("message:text", (ctx) => {
162
+ if (ctx.text === "plain") return ctx.reply("no markup");
163
+ return ctx.reply("with markup", {
164
+ reply_markup: { inline_keyboard: [[{ text: "x", callback_data: "x" }]] },
165
+ });
166
+ });
167
+ const env = createTestEnv(bot);
168
+ const user = env.createUser();
169
+ const group = env.createChat({ type: "group" });
170
+
171
+ await user.sendMessage("plain");
172
+ await user.sendMessage(group, "keyboard");
173
+
174
+ assert.equal(env.lastBotMessage({ chat: user.pmChat })?.text, "no markup");
175
+ assert.equal(env.lastBotMessage({ withReplyMarkup: true })?.chat.id, group.id);
176
+ assert.equal(
177
+ env.lastBotMessage({
178
+ where: (call) => call.method === "sendMessage" && call.params?.chat_id === group.id,
179
+ })?.text,
180
+ "with markup",
181
+ );
182
+ });
183
+
184
+ test("react() infers old_reaction from this user's last reaction on the message", async () => {
185
+ const seen: Array<{ old: string[]; new: string[] }> = [];
186
+ const bot = new Composer<Context>().on("message_reaction", (ctx) => {
187
+ const r = ctx.update.message_reaction;
188
+ if (!r) return;
189
+ seen.push({
190
+ old: r.old_reaction.map((x) => ("emoji" in x ? x.emoji : "")),
191
+ new: r.new_reaction.map((x) => ("emoji" in x ? x.emoji : "")),
192
+ });
193
+ });
194
+
195
+ const env = createTestEnv(bot);
196
+ const user = env.createUser();
197
+ const msg = await user.sendMessage("nice bot");
198
+
199
+ await user.react("👍", msg);
200
+ await user.react("❤", msg);
201
+ await user.react([], msg);
202
+
203
+ assert.deepEqual(seen[0], { old: [], new: ["👍"] });
204
+ assert.deepEqual(seen[1], { old: ["👍"], new: ["❤"] });
205
+ assert.deepEqual(seen[2], { old: ["❤"], new: [] });
206
+ });
207
+
208
+ test("join()/leave() track chat.members and dispatch chat_member + a service message", async () => {
209
+ const serviceMessages: string[] = [];
210
+ const bot = new Composer<Context>().on("message", (ctx) => {
211
+ if (ctx.message?.new_chat_members) serviceMessages.push("joined");
212
+ if (ctx.message?.left_chat_member) serviceMessages.push("left");
213
+ });
214
+
215
+ const env = createTestEnv(bot);
216
+ const user = env.createUser();
217
+ const group = env.createChat({ type: "group" });
218
+
219
+ await user.join(group);
220
+ assert.equal(group.members.has(user), true);
221
+ assert.equal(group.membershipOf(user)?.status, "member");
222
+ assert.deepEqual(serviceMessages, ["joined"]);
223
+
224
+ await user.leave(group);
225
+ assert.equal(group.members.has(user), false);
226
+ assert.equal(group.membershipOf(user)?.status, "left");
227
+ assert.deepEqual(serviceMessages, ["joined", "left"]);
228
+ });
229
+
230
+ test("sendInlineQuery / chooseInlineResult dispatch their updates", async () => {
231
+ let query: string | undefined;
232
+ let chosen: string | undefined;
233
+ const bot = new Composer<Context>()
234
+ .on("inline_query", (ctx) => {
235
+ query = ctx.update.inline_query?.query;
236
+ })
237
+ .on("chosen_inline_result", (ctx) => {
238
+ chosen = ctx.update.chosen_inline_result?.result_id;
239
+ });
240
+
241
+ const env = createTestEnv(bot);
242
+ const user = env.createUser();
243
+ const group = env.createChat({ type: "group" });
244
+
245
+ await user.sendInlineQuery("cats", group);
246
+ await user.chooseInlineResult("r1", "cats");
247
+
248
+ assert.equal(query, "cats");
249
+ assert.equal(chosen, "r1");
250
+ });
251
+
252
+ test("sendSuccessfulPayment throws unless the bot answered pre_checkout_query with ok: true", async () => {
253
+ const notHandling = createTestEnv(new Composer<Context>());
254
+ await assert.rejects(
255
+ notHandling.createUser().sendSuccessfulPayment({ invoice_payload: "x" }),
256
+ /never answered the pre_checkout_query/,
257
+ );
258
+
259
+ const rejecting = createTestEnv(
260
+ new Composer<Context>().on("pre_checkout_query", async (ctx) => {
261
+ await ctx.api.call("answerPreCheckoutQuery", {
262
+ pre_checkout_query_id: ctx.update.pre_checkout_query?.id,
263
+ ok: false,
264
+ });
265
+ }),
266
+ );
267
+ await assert.rejects(rejecting.createUser().sendSuccessfulPayment({ invoice_payload: "x" }));
268
+
269
+ let paid: string | undefined;
270
+ const accepting = createTestEnv(
271
+ new Composer<Context>()
272
+ .on("pre_checkout_query", async (ctx) => {
273
+ await ctx.api.call("answerPreCheckoutQuery", {
274
+ pre_checkout_query_id: ctx.update.pre_checkout_query?.id,
275
+ ok: true,
276
+ });
277
+ })
278
+ .on("message", (ctx) => {
279
+ paid = ctx.message?.successful_payment?.invoice_payload;
280
+ }),
281
+ );
282
+ await accepting.createUser().sendSuccessfulPayment({ invoice_payload: "sub_monthly" });
283
+ assert.equal(paid, "sub_monthly");
284
+ });
285
+
286
+ test("onApi/apiError let a test simulate a Telegram failure the bot must handle", async () => {
287
+ const bot = new Composer<Context>().on("message:text", async (ctx) => {
288
+ try {
289
+ await ctx.reply("hi");
290
+ } catch {
291
+ // swallow — this bot doesn't retry, just shouldn't crash the test
292
+ }
293
+ });
294
+ const env = createTestEnv(bot);
295
+ env.onApi("sendMessage", apiError(403, "Forbidden: bot was blocked by the user"));
296
+
297
+ await env.createUser().sendMessage("hi");
298
+ assert.equal(env.lastApiCall("sendMessage")?.error instanceof Error, true);
299
+ });
300
+
301
+ test("strictDispatch throws when no handler consumes the update", async () => {
302
+ const env = createTestEnv(new Composer<Context>(), { strictDispatch: true });
303
+ await assert.rejects(env.createUser().sendMessage("hi"), /no handler consumed/);
304
+ });
305
+
306
+ test("advanceTime() lets code under test skip a real setTimeout wait", async () => {
307
+ let resumed = false;
308
+ const bot = new Composer<Context>().on("message", () => {
309
+ setTimeout(() => {
310
+ resumed = true;
311
+ }, 5000);
312
+ });
313
+
314
+ const env = createTestEnv(bot);
315
+ env.useFakeTimers(); // arm before the handler schedules its setTimeout
316
+ await env.createUser().sendMessage("hi");
317
+
318
+ assert.equal(resumed, false);
319
+ await env.advanceTime(5000);
320
+ assert.equal(resumed, true);
321
+
322
+ env.shutdown();
323
+ });
324
+
325
+ test("chat.post() emits an anonymous channel_post and throws on non-channel chats", async () => {
326
+ let fromIsUndefined = false;
327
+ const bot = new Composer<Context>().on("channel_post", (ctx) => {
328
+ fromIsUndefined = ctx.update.channel_post?.from === undefined;
329
+ });
330
+ const env = createTestEnv(bot);
331
+ const channel = env.createChat({ type: "channel", title: "News" });
332
+ const group = env.createChat({ type: "group" });
333
+
334
+ await channel.post("breaking news");
335
+ assert.equal(fromIsUndefined, true);
336
+
337
+ await assert.rejects(group.post("nope"), /only channel chats can post/);
338
+ });
339
+
340
+ test("onPostDispatch hooks run after each dispatched update, in registration order", async () => {
341
+ const order: string[] = [];
342
+ const env = createTestEnv(new Composer<Context>());
343
+ env.onPostDispatch(() => {
344
+ order.push("first");
345
+ });
346
+ env.onPostDispatch(() => {
347
+ order.push("second");
348
+ });
349
+
350
+ await env.createUser().sendMessage("hi");
351
+ assert.deepEqual(order, ["first", "second"]);
352
+ });
353
+
354
+ test("packs: setup(env) runs at construction time and can seed api overrides", () => {
355
+ let ran = false;
356
+ const env = createTestEnv(new Composer<Context>(), {
357
+ packs: [
358
+ {
359
+ name: "probe",
360
+ setup(e) {
361
+ ran = true;
362
+ e.onApi("getMe", { id: 99, is_bot: true, first_name: "probe" });
363
+ },
364
+ },
365
+ ],
366
+ });
367
+
368
+ assert.equal(ran, true);
369
+ return env.api.getMe().then((me) => assert.equal(me.id, 99));
370
+ });