@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/src/env.test.ts
ADDED
|
@@ -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
|
+
});
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { Update } from "@yaebal/core";
|
|
2
|
+
import { type Api, type Composer, Context, type NextFn } from "@yaebal/core";
|
|
3
|
+
import type { RecordedCall } from "./api.js";
|
|
4
|
+
import { type MockApi, type MockResult, mockApi } from "./api.js";
|
|
5
|
+
import {
|
|
6
|
+
attachBotMessageTracking,
|
|
7
|
+
type BotMessage,
|
|
8
|
+
type BotMessageTracker,
|
|
9
|
+
type LastBotMessageQuery,
|
|
10
|
+
} from "./bot-messages.js";
|
|
11
|
+
import { ChatActor, type ChatType, type CreateChatOptions } from "./chat-actor.js";
|
|
12
|
+
import { installTestClock, type TestClock } from "./clock.js";
|
|
13
|
+
import type { ActorHost } from "./internal.js";
|
|
14
|
+
import { type BuildUserOptions, buildUser, createUpdate, detectUpdateType } from "./updates.js";
|
|
15
|
+
import { UserActor } from "./user-actor.js";
|
|
16
|
+
|
|
17
|
+
export type { ApiErrorSentinel, MockResult, OnApiOptions, RecordedCall } from "./api.js";
|
|
18
|
+
export { apiError, isApiErrorSentinel, TestApiError } from "./api.js";
|
|
19
|
+
export type { BotMessage, LastBotMessageQuery } from "./bot-messages.js";
|
|
20
|
+
export {
|
|
21
|
+
ChatActor,
|
|
22
|
+
type ChatMembership,
|
|
23
|
+
type ChatType,
|
|
24
|
+
type CreateChatOptions,
|
|
25
|
+
} from "./chat-actor.js";
|
|
26
|
+
export { installTestClock, type TestClock } from "./clock.js";
|
|
27
|
+
export {
|
|
28
|
+
type MediaOptions,
|
|
29
|
+
type MessageOptions,
|
|
30
|
+
UserActor,
|
|
31
|
+
UserInChatScope,
|
|
32
|
+
UserOnMessageScope,
|
|
33
|
+
} from "./user-actor.js";
|
|
34
|
+
|
|
35
|
+
/** a satellite plugin's canned test fixtures — pass to {@link createTestEnv} via `options.packs`. explicit (you always list which packs apply), matching yaebal's "no implicit plugin wiring" rule. */
|
|
36
|
+
export interface TestPack<C extends Context = Context> {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
setup(env: TestEnv<C>): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TestEnvOptions<C extends Context = Context> {
|
|
42
|
+
/** seed permanent per-method replies — same as calling `env.onApi(method, result)` for each entry. */
|
|
43
|
+
results?: Record<string, MockResult>;
|
|
44
|
+
/** throw instead of returning `{}` when a method has no builtin default and no override. default `false`. */
|
|
45
|
+
strictApi?: boolean;
|
|
46
|
+
/** throw if an actor-driven update falls through the whole bot with no handler consuming it. default `false`. */
|
|
47
|
+
strictDispatch?: boolean;
|
|
48
|
+
/** satellite-plugin test packs to apply (see {@link TestPack}). */
|
|
49
|
+
packs?: TestPack<C>[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* the actor-driven test environment: wraps a `Composer`/`Bot`, intercepts every outgoing api
|
|
54
|
+
* call (no real HTTP), and hands out {@link UserActor}/{@link ChatActor} actors that dispatch
|
|
55
|
+
* real updates through it — the way real Telegram users would.
|
|
56
|
+
*/
|
|
57
|
+
export class TestEnv<C extends Context = Context> implements ActorHost {
|
|
58
|
+
readonly api: Api;
|
|
59
|
+
readonly apiCalls: RecordedCall[];
|
|
60
|
+
readonly hooks: MockApi["hooks"];
|
|
61
|
+
readonly users: UserActor[] = [];
|
|
62
|
+
readonly chats: ChatActor[] = [];
|
|
63
|
+
|
|
64
|
+
private readonly bot: Composer<C>;
|
|
65
|
+
private readonly mock: MockApi;
|
|
66
|
+
private readonly botMessages: BotMessageTracker;
|
|
67
|
+
private readonly strictDispatch: boolean;
|
|
68
|
+
private readonly postDispatchHooks: Array<(update: Update) => void | Promise<void>> = [];
|
|
69
|
+
private clock: TestClock | undefined;
|
|
70
|
+
private messageIdCounter = 0;
|
|
71
|
+
|
|
72
|
+
constructor(bot: Composer<C>, options: TestEnvOptions<C> = {}) {
|
|
73
|
+
this.bot = bot;
|
|
74
|
+
this.strictDispatch = options.strictDispatch ?? false;
|
|
75
|
+
|
|
76
|
+
this.mock = mockApi({
|
|
77
|
+
results: options.results,
|
|
78
|
+
strictApi: options.strictApi,
|
|
79
|
+
now: () => this.now(),
|
|
80
|
+
});
|
|
81
|
+
this.api = this.mock.api;
|
|
82
|
+
this.apiCalls = this.mock.calls;
|
|
83
|
+
this.hooks = this.mock.hooks;
|
|
84
|
+
this.botMessages = attachBotMessageTracking(this.api, (chatId) => this.resolveChatType(chatId));
|
|
85
|
+
|
|
86
|
+
for (const pack of options.packs ?? []) pack.setup(this);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** the clock's current time if {@link advanceTime} has installed one, else the real `Date.now()`. */
|
|
90
|
+
now(): number {
|
|
91
|
+
return this.clock ? this.clock.now() : Date.now();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
nextMessageId(): number {
|
|
95
|
+
return ++this.messageIdCounter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private resolveChatType(chatId: number): ChatType {
|
|
99
|
+
for (const chat of this.chats) if (chat.id === chatId) return chat.type;
|
|
100
|
+
for (const user of this.users) if (user.pmChat.id === chatId) return "private";
|
|
101
|
+
|
|
102
|
+
return "private";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** create a user actor. auto-allocates an id unless one is given. */
|
|
106
|
+
createUser(options: BuildUserOptions = {}): UserActor {
|
|
107
|
+
const user = new UserActor(this, buildUser(options));
|
|
108
|
+
this.users.push(user);
|
|
109
|
+
return user;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** create a chat actor (group/supergroup/channel/private). */
|
|
113
|
+
createChat(options: CreateChatOptions): ChatActor {
|
|
114
|
+
const chat = new ChatActor(this, options);
|
|
115
|
+
this.chats.push(chat);
|
|
116
|
+
return chat;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* dispatch a raw {@link Update} through the bot — the escape hatch beneath every actor
|
|
121
|
+
* method, for update shapes the actors don't cover (business connections, exotic service
|
|
122
|
+
* messages, whatever ships in the next Bot API release before the actors catch up).
|
|
123
|
+
*/
|
|
124
|
+
async dispatch(update: Update): Promise<void> {
|
|
125
|
+
const ctx = new Context({
|
|
126
|
+
api: this.api,
|
|
127
|
+
update,
|
|
128
|
+
updateType: detectUpdateType(update),
|
|
129
|
+
}) as unknown as C;
|
|
130
|
+
|
|
131
|
+
let reachedEnd = false;
|
|
132
|
+
const sentinel: NextFn = async () => {
|
|
133
|
+
reachedEnd = true;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
await this.bot.toMiddleware()(ctx, sentinel);
|
|
137
|
+
|
|
138
|
+
if (this.strictDispatch && reachedEnd) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`TestEnv: no handler consumed a "${detectUpdateType(update)}" update (strictDispatch is on)`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const hook of this.postDispatchHooks) await hook(update);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** alias for {@link dispatch} — ships an arbitrary update payload, puregram-style naming. */
|
|
148
|
+
inject(update: Update): Promise<void> {
|
|
149
|
+
return this.dispatch(update);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** run `fn` after every actor-driven (or `dispatch`ed) update finishes. hooks compose in registration order. */
|
|
153
|
+
onPostDispatch(fn: (update: Update) => void | Promise<void>): void {
|
|
154
|
+
this.postDispatchHooks.push(fn);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** override a method's reply. permanent unless `opts.times` is given. */
|
|
158
|
+
onApi(...args: Parameters<MockApi["onApi"]>): void {
|
|
159
|
+
this.mock.onApi(...args);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** drop a method's overrides (or every method's, if none given). */
|
|
163
|
+
offApi(method?: string): void {
|
|
164
|
+
this.mock.offApi(method);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** the most recent recorded call, optionally filtered to a method. */
|
|
168
|
+
lastApiCall(method?: string): RecordedCall | undefined {
|
|
169
|
+
return this.mock.lastCall(method);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** every recorded call to a given method, in call order. */
|
|
173
|
+
callsTo(method: string): RecordedCall[] {
|
|
174
|
+
return this.mock.callsTo(method);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** empty `apiCalls` and drop tracked bot messages — useful between logical phases of a test. */
|
|
178
|
+
clearApiCalls(): void {
|
|
179
|
+
this.mock.reset();
|
|
180
|
+
this.botMessages.clear();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** the bot's most recent `send*`/`forwardMessage`/`copyMessage`, optionally filtered; kept in sync with later edits. */
|
|
184
|
+
lastBotMessage(query?: LastBotMessageQuery): BotMessage | undefined {
|
|
185
|
+
return this.botMessages.lastBotMessage(query);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** look up a specific bot message by `(chat_id, message_id)`. */
|
|
189
|
+
botMessage(chatId: number, messageId: number): BotMessage | undefined {
|
|
190
|
+
return this.botMessages.botMessage(chatId, messageId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* arm the virtual clock (a no-op if one is already installed) — call this *before* triggering
|
|
195
|
+
* the code that schedules the timer you plan to fast-forward with {@link advanceTime}, the
|
|
196
|
+
* same way you'd call `vi.useFakeTimers()` before the code under test runs. a timer scheduled
|
|
197
|
+
* against the real clock before this call is invisible to `advanceTime` — it was never handed
|
|
198
|
+
* to the virtual scheduler.
|
|
199
|
+
*/
|
|
200
|
+
useFakeTimers(): void {
|
|
201
|
+
this.clock ??= installTestClock();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** advance the (auto-armed) virtual clock by `ms`, firing due timers. see {@link installTestClock}. */
|
|
205
|
+
async advanceTime(ms: number): Promise<void> {
|
|
206
|
+
this.useFakeTimers();
|
|
207
|
+
await (this.clock as TestClock).advance(ms);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** did the bot answer `answerPreCheckoutQuery` for `preCheckoutQueryId` with `ok: true`? used by `sendSuccessfulPayment`. */
|
|
211
|
+
answeredPreCheckoutQuery(preCheckoutQueryId: string): boolean {
|
|
212
|
+
return this.callsTo("answerPreCheckoutQuery").some(
|
|
213
|
+
(call) =>
|
|
214
|
+
!call.error &&
|
|
215
|
+
call.params?.pre_checkout_query_id === preCheckoutQueryId &&
|
|
216
|
+
call.params?.ok === true,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** restore the virtual clock (if one was installed) to real timers. call in `afterEach`/teardown. */
|
|
221
|
+
shutdown(): void {
|
|
222
|
+
this.clock?.restore();
|
|
223
|
+
this.clock = undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** create a {@link TestEnv} wrapping `bot` — the main entry point of `@yaebal/test`. */
|
|
228
|
+
export function createTestEnv<C extends Context = Context>(
|
|
229
|
+
bot: Composer<C>,
|
|
230
|
+
options?: TestEnvOptions<C>,
|
|
231
|
+
): TestEnv<C> {
|
|
232
|
+
return new TestEnv(bot, options);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { createUpdate };
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** stub `globalThis.fetch` for the duration of `fn`, restoring the original afterwards (even on throw). */
|
|
2
|
+
export async function withFetch<T>(handler: typeof fetch, fn: () => T | Promise<T>): Promise<T> {
|
|
3
|
+
const realFetch = globalThis.fetch;
|
|
4
|
+
globalThis.fetch = handler;
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
return await fn();
|
|
8
|
+
} finally {
|
|
9
|
+
globalThis.fetch = realFetch;
|
|
10
|
+
}
|
|
11
|
+
}
|