@yaebal/test 0.1.0 → 0.2.1

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 +334 -160
  2. package/lib/api.d.ts +82 -0
  3. package/lib/api.d.ts.map +1 -0
  4. package/lib/api.js +183 -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 +281 -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,180 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { TelegramError } from "@yaebal/core";
4
+ import { apiError, isApiErrorSentinel, mockApi, TestApiError } from "./api.js";
5
+
6
+ test("mockApi records calls and resolves sensible defaults", async () => {
7
+ const { api, calls } = mockApi();
8
+
9
+ const sent = await api.sendMessage({ chat_id: 1, text: "hi" });
10
+ assert.deepEqual(sent, { message_id: 1 });
11
+
12
+ const answered = await api.answerCallbackQuery({ callback_query_id: "1" });
13
+ assert.equal(answered, true);
14
+
15
+ const viaCall = await api.call("setMyCommands", { commands: [] });
16
+ assert.deepEqual(viaCall, {});
17
+
18
+ assert.equal(calls.length, 3);
19
+ assert.equal(calls[0]?.method, "sendMessage");
20
+ assert.deepEqual(calls[0]?.params, { chat_id: 1, text: "hi" });
21
+ assert.deepEqual(calls[0]?.result, { message_id: 1 });
22
+ assert.equal(typeof calls[0]?.at, "number");
23
+ });
24
+
25
+ test("mockApi hook registrars are chainable", () => {
26
+ const { api } = mockApi();
27
+ assert.equal(
28
+ api.before(() => undefined),
29
+ api,
30
+ );
31
+ assert.equal(
32
+ api.after((_m, _p, r) => r),
33
+ api,
34
+ );
35
+ });
36
+
37
+ test("mockApi auto-increments message_id across send* calls", async () => {
38
+ const { api } = mockApi();
39
+
40
+ const first = await api.sendMessage({ chat_id: 1, text: "a" });
41
+ const second = await api.sendMessage({ chat_id: 1, text: "b" });
42
+
43
+ assert.deepEqual(first, { message_id: 1 });
44
+ assert.deepEqual(second, { message_id: 2 });
45
+ });
46
+
47
+ test("mockApi results: static override replaces the built-in default", async () => {
48
+ const { api } = mockApi({ results: { sendMessage: { message_id: 99 } } });
49
+
50
+ assert.deepEqual(await api.sendMessage({ chat_id: 1 }), { message_id: 99 });
51
+ });
52
+
53
+ test("mockApi results: an Error value makes the call throw", async () => {
54
+ const { api } = mockApi({
55
+ results: { sendMessage: new TelegramError("sendMessage", 400, "Bad Request") },
56
+ });
57
+
58
+ await assert.rejects(api.sendMessage({ chat_id: 1 }), TelegramError);
59
+ });
60
+
61
+ test("mockApi results: a function sees params and a running attempt count", async () => {
62
+ const { api } = mockApi({
63
+ results: {
64
+ sendMessage: (params: Record<string, unknown> | undefined, attempt: number) =>
65
+ attempt <= 2
66
+ ? new TelegramError("sendMessage", 429, "Too Many Requests", { retry_after: 0 })
67
+ : { message_id: 1, echo: params?.text },
68
+ },
69
+ });
70
+
71
+ api.onError((_m, _e, attempt) => (attempt <= 2 ? { retry: true } : undefined));
72
+
73
+ const result = await api.sendMessage({ chat_id: 1, text: "hi" });
74
+ assert.deepEqual(result, { message_id: 1, echo: "hi" });
75
+ });
76
+
77
+ test("onApi sets a permanent override; reset() clears calls and counters but keeps it", async () => {
78
+ const { api, calls, onApi, reset } = mockApi();
79
+
80
+ await api.sendMessage({ chat_id: 1 });
81
+ onApi("sendMessage", { message_id: 42 });
82
+ assert.deepEqual(await api.sendMessage({ chat_id: 1 }), { message_id: 42 });
83
+ assert.equal(calls.length, 2);
84
+
85
+ reset();
86
+ assert.equal(calls.length, 0);
87
+ assert.deepEqual(await api.sendMessage({ chat_id: 1 }), { message_id: 42 });
88
+ });
89
+
90
+ test("onApi with { times } is one-shot, then falls back to the previous permanent reply", async () => {
91
+ const { api, onApi } = mockApi({ results: { sendMessage: { message_id: 1 } } });
92
+
93
+ onApi("sendMessage", apiError(429, "Too Many Requests"), { times: 1 });
94
+
95
+ await assert.rejects(api.sendMessage({ chat_id: 1 }), TestApiError);
96
+ assert.deepEqual(await api.sendMessage({ chat_id: 1 }), { message_id: 1 });
97
+ });
98
+
99
+ test("onApi { times: 2 } is consumed over exactly two calls", async () => {
100
+ const { api, onApi } = mockApi();
101
+
102
+ onApi("getChat", { id: 1, type: "private", stale: true }, { times: 2 });
103
+ onApi("getChat", { id: 1, type: "private", stale: false });
104
+
105
+ assert.equal((await api.call<{ stale: boolean }>("getChat")).stale, true);
106
+ assert.equal((await api.call<{ stale: boolean }>("getChat")).stale, true);
107
+ assert.equal((await api.call<{ stale: boolean }>("getChat")).stale, false);
108
+ });
109
+
110
+ test("offApi drops a method's override, or every override with no argument", async () => {
111
+ const { api, onApi, offApi } = mockApi();
112
+
113
+ onApi("sendMessage", { message_id: 99 });
114
+ offApi("sendMessage");
115
+ assert.deepEqual(await api.sendMessage({ chat_id: 1 }), { message_id: 1 });
116
+
117
+ onApi("getMe", { id: 7, is_bot: true, first_name: "x" });
118
+ offApi();
119
+ assert.deepEqual(await api.getMe(), { id: 1, is_bot: true, first_name: "bot", username: "bot" });
120
+ });
121
+
122
+ test("apiError() produces a sentinel that throws a TestApiError carrying parameters", async () => {
123
+ const sentinel = apiError(429, "Too Many Requests", { retry_after: 30 });
124
+ assert.ok(isApiErrorSentinel(sentinel));
125
+
126
+ const { api } = mockApi({ results: { sendMessage: sentinel } });
127
+
128
+ await assert.rejects(api.sendMessage({ chat_id: 1 }), (error: unknown) => {
129
+ assert.ok(error instanceof TestApiError);
130
+ assert.ok(error instanceof TelegramError);
131
+ assert.equal(error.code, 429);
132
+ assert.deepEqual(error.parameters, { retry_after: 30 });
133
+ return true;
134
+ });
135
+ });
136
+
137
+ test("strictApi throws for methods with no builtin default and no override", async () => {
138
+ const { api } = mockApi({ strictApi: true });
139
+
140
+ await assert.rejects(api.call("getChatMember"), /no stub for "getChatMember"/);
141
+ assert.deepEqual(await api.sendMessage({ chat_id: 1 }), { message_id: 1 });
142
+ });
143
+
144
+ test("mockApi.lastCall and callsTo filter recorded calls", async () => {
145
+ const { api, lastCall, callsTo } = mockApi();
146
+
147
+ await api.sendMessage({ chat_id: 1, text: "a" });
148
+ await api.answerCallbackQuery({ callback_query_id: "1" });
149
+ await api.sendMessage({ chat_id: 1, text: "b" });
150
+
151
+ assert.equal(lastCall()?.method, "sendMessage");
152
+ assert.equal(lastCall("answerCallbackQuery")?.method, "answerCallbackQuery");
153
+ assert.equal(lastCall("sendMessage")?.params?.text, "b");
154
+ assert.equal(callsTo("sendMessage").length, 2);
155
+ });
156
+
157
+ test("mockApi hooks actually run: before rewrites params, after rewrites result", async () => {
158
+ const { api } = mockApi();
159
+
160
+ api.before((_method, params) => ({ ...params, injected: true }));
161
+ api.after((_method, _params, result) => ({ ...(result as object), tagged: true }));
162
+
163
+ const result = await api.sendMessage({ chat_id: 1 });
164
+
165
+ assert.deepEqual(result, { message_id: 1, tagged: true });
166
+ });
167
+
168
+ test("reply_markup builder instances are normalized to plain JSON on recorded calls", async () => {
169
+ const { api, calls } = mockApi();
170
+
171
+ await api.sendMessage({
172
+ chat_id: 1,
173
+ text: "hi",
174
+ reply_markup: { toJSON: () => ({ inline_keyboard: [[{ text: "ok", callback_data: "ok" }]] }) },
175
+ });
176
+
177
+ assert.deepEqual(calls[0]?.params?.reply_markup, {
178
+ inline_keyboard: [[{ text: "ok", callback_data: "ok" }]],
179
+ });
180
+ });
package/src/api.ts ADDED
@@ -0,0 +1,281 @@
1
+ import {
2
+ type AfterHook,
3
+ type Api,
4
+ type BeforeHook,
5
+ type ErrorAction,
6
+ type ErrorHook,
7
+ type ResponseParameters,
8
+ TelegramError,
9
+ } from "@yaebal/core";
10
+ import { normalizeParams } from "./normalize.js";
11
+
12
+ /** a single recorded api call: the method, the params it was given, and how it resolved. */
13
+ export interface RecordedCall {
14
+ method: string;
15
+ params: Record<string, unknown> | undefined;
16
+ /** the resolved result, if the call succeeded. */
17
+ result?: unknown;
18
+ /** the thrown error, if the call failed (and no `onError` hook rescued it). */
19
+ error?: unknown;
20
+ /** ms since epoch when the call was made — honors an installed {@link TestClock}. */
21
+ at: number;
22
+ }
23
+
24
+ /** a canned result for one method: a static value, an {@link ApiErrorSentinel}/`Error`, or a function of `(params, attempt)`. */
25
+ export type MockResult =
26
+ | unknown
27
+ | Error
28
+ | ApiErrorSentinel
29
+ | ((params: Record<string, unknown> | undefined, attempt: number) => unknown);
30
+
31
+ /** the shape `apiError(...)` returns — a sentinel telling the mock to throw a {@link TestApiError}. */
32
+ export interface ApiErrorSentinel {
33
+ readonly __yaebalApiError: true;
34
+ code: number;
35
+ description: string;
36
+ parameters?: ResponseParameters;
37
+ }
38
+
39
+ /**
40
+ * simulate a real Telegram Bot API error response — the bot sees exactly what it would see
41
+ * from the real api: a thrown {@link TestApiError} (a {@link TelegramError} subclass).
42
+ *
43
+ * @example
44
+ * env.onApi("sendMessage", apiError(403, "Forbidden: bot was blocked by the user"));
45
+ * env.onApi("sendMessage", apiError(429, "Too Many Requests", { retry_after: 30 }));
46
+ */
47
+ export function apiError(
48
+ code: number,
49
+ description: string,
50
+ parameters?: ResponseParameters,
51
+ ): ApiErrorSentinel {
52
+ return { __yaebalApiError: true, code, description, parameters };
53
+ }
54
+
55
+ /** typeguard for a stored `apiError(...)` sentinel. */
56
+ export function isApiErrorSentinel(value: unknown): value is ApiErrorSentinel {
57
+ return typeof value === "object" && value !== null && "__yaebalApiError" in value;
58
+ }
59
+
60
+ /** a {@link TelegramError} carrying the optional `parameters` bag (e.g. `retry_after`) real errors ship. */
61
+ export class TestApiError extends TelegramError {
62
+ constructor(method: string, code: number, description: string, parameters?: ResponseParameters) {
63
+ super(method, code, description, parameters);
64
+ }
65
+ }
66
+
67
+ /** options accepted by `{ times }` overrides: one-shot (or N-shot) replies. */
68
+ export interface OnApiOptions {
69
+ /** consume this override for exactly `times` calls, then fall back to the next queued/permanent reply. default: permanent (never expires). */
70
+ times?: number;
71
+ }
72
+
73
+ export interface MockApiOptions {
74
+ /** per-method canned results/errors, keyed by method name — seeds the permanent override, same as calling `onApi(method, result)` for each entry. */
75
+ results?: Record<string, MockResult>;
76
+ /** throw instead of falling back to `{}` when a method has no builtin default and no override registered. catches "forgot to stub this" in tests that care. default `false`. */
77
+ strictApi?: boolean;
78
+ /** clock to timestamp recorded calls with; defaults to the real `Date.now`. */
79
+ now?: () => number;
80
+ }
81
+
82
+ /** result of {@link mockApi}: the fake `api`, its recorded calls, and inspection/stubbing helpers. */
83
+ export interface MockApi {
84
+ api: Api;
85
+ calls: RecordedCall[];
86
+ /** hooks registered on `api` via `before`/`after`/`onError` — inspect them or invoke them yourself. */
87
+ hooks: { before: BeforeHook[]; after: AfterHook[]; onError: ErrorHook[] };
88
+ /** the most recent recorded call, optionally filtered to a method. */
89
+ lastCall(method?: string): RecordedCall | undefined;
90
+ /** every recorded call to a given method, in call order. */
91
+ callsTo(method: string): RecordedCall[];
92
+ /** override a method's reply. permanent unless `opts.times` is given (then it's consumed after `times` calls and the previous permanent, if any, resumes). */
93
+ onApi(method: string, reply: MockResult, opts?: OnApiOptions): void;
94
+ /** drop a method's overrides (or every method's, if none given) — reverts to the builtin default. */
95
+ offApi(method?: string): void;
96
+ /** clear recorded calls and per-method attempt counters. keeps hooks and overrides. */
97
+ reset(): void;
98
+ }
99
+
100
+ /** default results for known methods; everything else resolves to `{}` (or throws, with `strictApi`). */
101
+ const STUBBED_METHODS = new Set(["getMe", "answerCallbackQuery"]);
102
+
103
+ function builtinResult(method: string, nextMessageId: () => number): unknown {
104
+ if (method.startsWith("send") || method === "copyMessage" || method === "forwardMessage") {
105
+ return { message_id: nextMessageId() };
106
+ }
107
+
108
+ if (method === "answerCallbackQuery") return true;
109
+ if (method === "getMe") return { id: 1, is_bot: true, first_name: "bot", username: "bot" };
110
+
111
+ return {};
112
+ }
113
+
114
+ function hasBuiltin(method: string): boolean {
115
+ return (
116
+ method.startsWith("send") ||
117
+ method === "copyMessage" ||
118
+ method === "forwardMessage" ||
119
+ STUBBED_METHODS.has(method)
120
+ );
121
+ }
122
+
123
+ interface QueuedReply {
124
+ reply: MockResult;
125
+ remaining: number;
126
+ }
127
+
128
+ interface MethodOverride {
129
+ queue: QueuedReply[];
130
+ permanent?: MockResult;
131
+ }
132
+
133
+ /**
134
+ * a fake {@link Api} whose every method records `{ method, params, result | error, at }` into
135
+ * `calls` and resolves to a sensible default (auto-incrementing `message_id` for `send*`, `true`
136
+ * for `answerCallbackQuery`, `{}` otherwise) — or to whatever `onApi`/`options.results` says.
137
+ * `before`/`after`/`onError` are real hook registrars (not no-ops): register a hook the same way
138
+ * you would on the production `Api` and it actually runs on each attempt, including retries requested by an
139
+ * `onError` hook. the mock never actually waits on a requested `delayMs` — retries settle
140
+ * instantly, so tests stay fast (pair with {@link installTestClock} if the code under test
141
+ * schedules the retry itself via `setTimeout`).
142
+ */
143
+ export function mockApi(options: MockApiOptions = {}): MockApi {
144
+ const calls: RecordedCall[] = [];
145
+ const overrides = new Map<string, MethodOverride>();
146
+ const attempts = new Map<string, number>();
147
+ const now = options.now ?? (() => Date.now());
148
+ let nextMessageId = 1;
149
+
150
+ for (const [method, reply] of Object.entries(options.results ?? {})) {
151
+ overrides.set(method, { queue: [], permanent: reply });
152
+ }
153
+
154
+ const hooks = {
155
+ before: [] as BeforeHook[],
156
+ after: [] as AfterHook[],
157
+ onError: [] as ErrorHook[],
158
+ };
159
+
160
+ function resolveReply(method: string, params: Record<string, unknown> | undefined): unknown {
161
+ const attempt = (attempts.get(method) ?? 0) + 1;
162
+ attempts.set(method, attempt);
163
+
164
+ const override = overrides.get(method);
165
+ let reply: MockResult | undefined;
166
+
167
+ if (override?.queue.length) {
168
+ const head = override.queue[0] as QueuedReply;
169
+ head.remaining--;
170
+ reply = head.reply;
171
+ if (head.remaining <= 0) override.queue.shift();
172
+ } else if (override && "permanent" in override) {
173
+ reply = override.permanent;
174
+ } else if (options.strictApi && !hasBuiltin(method)) {
175
+ throw new Error(
176
+ `mockApi: no stub for "${method}" — register one with onApi("${method}", ...), or disable strictApi`,
177
+ );
178
+ } else {
179
+ return builtinResult(method, () => nextMessageId++);
180
+ }
181
+
182
+ if (typeof reply === "function") {
183
+ return (reply as (p: typeof params, a: number) => unknown)(params, attempt);
184
+ }
185
+
186
+ if (isApiErrorSentinel(reply)) {
187
+ return new TestApiError(method, reply.code, reply.description, reply.parameters);
188
+ }
189
+
190
+ return reply;
191
+ }
192
+
193
+ const call = async (method: string, rawParams?: Record<string, unknown>): Promise<never> => {
194
+ for (let attempt = 1; ; attempt++) {
195
+ let p = normalizeParams(rawParams);
196
+ for (const hook of hooks.before) {
197
+ const next = await hook(method, p);
198
+ if (next !== undefined) p = next;
199
+ }
200
+
201
+ let result: unknown;
202
+
203
+ try {
204
+ result = resolveReply(method, p);
205
+ if (result instanceof Error) throw result;
206
+ } catch (error) {
207
+ let retry: ErrorAction | undefined;
208
+ for (const hook of hooks.onError) {
209
+ const action = await hook(method, error, attempt, p);
210
+ if (!retry && action?.retry) {
211
+ retry = action;
212
+ }
213
+ }
214
+
215
+ calls.push({ method, params: p, error, at: now() });
216
+ if (!retry) throw error;
217
+ continue; // the mock never actually waits on retry.delayMs
218
+ }
219
+
220
+ for (const hook of hooks.after) {
221
+ const next = await hook(method, p, result);
222
+ if (next !== undefined) result = next;
223
+ }
224
+
225
+ calls.push({ method, params: p, result, at: now() });
226
+ return result as never;
227
+ }
228
+ };
229
+
230
+ const registrar: Record<string, unknown> = {
231
+ call: (method: string, params?: Record<string, unknown>) => call(method, params),
232
+ fileUrl: (filePath: string) => `https://example.invalid/file/${filePath}`,
233
+ before(hook: BeforeHook) {
234
+ hooks.before.push(hook);
235
+ return api;
236
+ },
237
+ after(hook: AfterHook) {
238
+ hooks.after.push(hook);
239
+ return api;
240
+ },
241
+ onError(hook: ErrorHook) {
242
+ hooks.onError.push(hook);
243
+ return api;
244
+ },
245
+ };
246
+
247
+ const api = new Proxy(registrar, {
248
+ get(obj, prop: string) {
249
+ if (prop in obj) return obj[prop];
250
+
251
+ const method = (params?: Record<string, unknown>) => call(prop, params);
252
+ obj[prop] = method;
253
+
254
+ return method;
255
+ },
256
+ }) as unknown as Api;
257
+
258
+ return {
259
+ api,
260
+ calls,
261
+ hooks,
262
+ lastCall: (method) =>
263
+ method ? [...calls].reverse().find((c) => c.method === method) : calls.at(-1),
264
+ callsTo: (method) => calls.filter((c) => c.method === method),
265
+ onApi: (method, reply, opts) => {
266
+ const entry = overrides.get(method) ?? { queue: [] };
267
+ if (opts?.times !== undefined) entry.queue.push({ reply, remaining: opts.times });
268
+ else entry.permanent = reply;
269
+ overrides.set(method, entry);
270
+ },
271
+ offApi: (method) => {
272
+ if (method) overrides.delete(method);
273
+ else overrides.clear();
274
+ },
275
+ reset: () => {
276
+ calls.length = 0;
277
+ attempts.clear();
278
+ nextMessageId = 1;
279
+ },
280
+ };
281
+ }
@@ -0,0 +1,117 @@
1
+ import type { Api, Chat, Message } from "@yaebal/core";
2
+
3
+ /** `Message`'s own `reply_markup` union — reused so a `BotMessage` structurally satisfies `Message`. */
4
+ type ReplyMarkup = NonNullable<Message["reply_markup"]>;
5
+
6
+ /**
7
+ * a live mirror of one message the bot sent — built from the outgoing `send*` params (so it's
8
+ * populated even against the builtin `{ message_id }` stub, no `onApi` override needed) and kept
9
+ * in sync in place as `editMessageText`/`editMessageCaption`/`editMessageReplyMarkup` calls land,
10
+ * so a reference captured before an edit still reflects it after.
11
+ */
12
+ export interface BotMessage {
13
+ message_id: number;
14
+ chat: { id: number; type: Chat["type"] };
15
+ text?: string;
16
+ caption?: string;
17
+ reply_markup?: ReplyMarkup;
18
+ date: number;
19
+ }
20
+
21
+ export interface LastBotMessageQuery {
22
+ /** scope to a specific chat (an `{ id }`-shaped value — a `ChatActor` or `pmChat` both qualify). */
23
+ chat?: { id: number };
24
+ /** only consider messages currently carrying a non-empty `inline_keyboard`. */
25
+ withReplyMarkup?: boolean;
26
+ /** arbitrary predicate over the call that produced (or last touched) the message. */
27
+ where?: (call: { method: string; params: Record<string, unknown> | undefined }) => boolean;
28
+ }
29
+
30
+ export interface BotMessageTracker {
31
+ lastBotMessage(query?: LastBotMessageQuery): BotMessage | undefined;
32
+ botMessage(chatId: number, messageId: number): BotMessage | undefined;
33
+ clear(): void;
34
+ }
35
+
36
+ const SEND_LIKE = (method: string): boolean =>
37
+ method.startsWith("send") || method === "forwardMessage" || method === "copyMessage";
38
+
39
+ function hasInlineKeyboard(markup: ReplyMarkup | undefined): boolean {
40
+ const withRows = markup as { inline_keyboard?: unknown[] } | undefined;
41
+ const rows = withRows?.inline_keyboard;
42
+ return Array.isArray(rows) && rows.length > 0;
43
+ }
44
+
45
+ /** install bot-message tracking on a mock `api` via its `after` hook. */
46
+ export function attachBotMessageTracking(
47
+ api: Api,
48
+ resolveChatType: (chatId: number) => Chat["type"],
49
+ ): BotMessageTracker {
50
+ interface Entry extends BotMessage {
51
+ lastCall: { method: string; params: Record<string, unknown> | undefined };
52
+ }
53
+
54
+ const byKey = new Map<string, Entry>();
55
+ const order: Entry[] = [];
56
+ const key = (chatId: number, messageId: number): string => `${chatId}:${messageId}`;
57
+
58
+ api.after((method, params, result) => {
59
+ const p = params ?? {};
60
+
61
+ if (method.startsWith("edit")) {
62
+ const chatId = p.chat_id as number | undefined;
63
+ const messageId = p.message_id as number | undefined;
64
+ if (chatId === undefined || messageId === undefined) return;
65
+
66
+ const entry = byKey.get(key(chatId, messageId));
67
+ if (!entry) return;
68
+
69
+ if (method === "editMessageText" && typeof p.text === "string") entry.text = p.text;
70
+ if (method === "editMessageCaption") entry.caption = p.caption as string | undefined;
71
+ if ("reply_markup" in p) entry.reply_markup = p.reply_markup as ReplyMarkup | undefined;
72
+
73
+ entry.lastCall = { method, params: p };
74
+ return;
75
+ }
76
+
77
+ if (!SEND_LIKE(method)) return;
78
+ if (!result || typeof result !== "object" || !("message_id" in result)) return;
79
+
80
+ const chatId = p.chat_id as number | undefined;
81
+ if (chatId === undefined) return;
82
+
83
+ const messageId = (result as { message_id: number }).message_id;
84
+ const entry: Entry = {
85
+ message_id: messageId,
86
+ chat: { id: chatId, type: resolveChatType(chatId) },
87
+ text: p.text as string | undefined,
88
+ caption: p.caption as string | undefined,
89
+ reply_markup: p.reply_markup as ReplyMarkup | undefined,
90
+ date: (result as { date?: number }).date ?? Date.now(),
91
+ lastCall: { method, params: p },
92
+ };
93
+
94
+ byKey.set(key(chatId, messageId), entry);
95
+ order.push(entry);
96
+ });
97
+
98
+ return {
99
+ lastBotMessage(query = {}) {
100
+ for (let i = order.length - 1; i >= 0; i--) {
101
+ const entry = order[i] as Entry;
102
+ if (query.chat && entry.chat.id !== query.chat.id) continue;
103
+ if (query.withReplyMarkup && !hasInlineKeyboard(entry.reply_markup)) continue;
104
+ if (query.where && !query.where(entry.lastCall)) continue;
105
+
106
+ return entry;
107
+ }
108
+
109
+ return undefined;
110
+ },
111
+ botMessage: (chatId, messageId) => byKey.get(key(chatId, messageId)),
112
+ clear() {
113
+ byKey.clear();
114
+ order.length = 0;
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,101 @@
1
+ import type { Chat, Message } from "@yaebal/core";
2
+ import type { ActorHost, SendText } from "./internal.js";
3
+ import { resolveSendText } from "./internal.js";
4
+ import { createUpdate } from "./updates.js";
5
+ import type { UserActor } from "./user-actor.js";
6
+
7
+ export type ChatType = "private" | "group" | "supergroup" | "channel";
8
+
9
+ export interface CreateChatOptions {
10
+ type: ChatType;
11
+ id?: number;
12
+ title?: string;
13
+ username?: string;
14
+ }
15
+
16
+ /** a chat member's tracked status — set via {@link ChatActor.setMembership}. */
17
+ export interface ChatMembership {
18
+ status: string;
19
+ since?: number;
20
+ }
21
+
22
+ let chatIdCounter = -1000;
23
+
24
+ /**
25
+ * a passive container actors send into — a group/supergroup/channel/private chat. tracks who's
26
+ * currently a member (best-effort — nothing enforces it unless you opt into `strictMembership`
27
+ * via {@link createTestEnv}) and every message dispatched through it.
28
+ */
29
+ export class ChatActor {
30
+ readonly id: number;
31
+ readonly type: ChatType;
32
+ readonly title?: string;
33
+ readonly username?: string;
34
+ readonly members = new Set<UserActor>();
35
+ readonly messages: unknown[] = [];
36
+
37
+ private readonly host: ActorHost;
38
+ private readonly membership = new Map<number, ChatMembership>();
39
+
40
+ constructor(host: ActorHost, options: CreateChatOptions) {
41
+ this.host = host;
42
+ this.id = options.id ?? chatIdCounter--;
43
+ this.type = options.type;
44
+ this.title = options.title;
45
+ this.username = options.username;
46
+ }
47
+
48
+ /** reconstruct a `ChatActor` from a raw `Chat` payload seen on an update (e.g. to find the chat a message you didn't create arrived in). not registered with any `TestEnv`. */
49
+ static fromChat(host: ActorHost, chat: Chat): ChatActor {
50
+ return new ChatActor(host, {
51
+ type: chat.type as ChatType,
52
+ id: chat.id,
53
+ title: chat.title,
54
+ username: chat.username,
55
+ });
56
+ }
57
+
58
+ /** this chat as a plain {@link Chat} payload, ready to embed in an update. */
59
+ toChat(): Chat {
60
+ return {
61
+ id: this.id,
62
+ type: this.type,
63
+ ...(this.title !== undefined ? { title: this.title } : {}),
64
+ ...(this.username !== undefined ? { username: this.username } : {}),
65
+ };
66
+ }
67
+
68
+ /** record (or update) a member's status — used by `strictMembership` and `getChatMember`-style assertions. */
69
+ setMembership(userId: number, membership: ChatMembership): void {
70
+ this.membership.set(userId, membership);
71
+ }
72
+
73
+ /** the tracked membership for a user, or `undefined` if never set. */
74
+ membershipOf(user: UserActor): ChatMembership | undefined {
75
+ return this.membership.get(user.id);
76
+ }
77
+
78
+ /**
79
+ * an anonymous channel post — `update.channel_post`, with no `from` (matching real Telegram:
80
+ * channel posts aren't attributed to a user). throws on non-channel chats.
81
+ */
82
+ async post(text: SendText): Promise<Message> {
83
+ if (this.type !== "channel") {
84
+ throw new Error("ChatActor.post(): only channel chats can post — did you mean a user actor?");
85
+ }
86
+
87
+ const { text: resolvedText, entities } = resolveSendText(text);
88
+ const message = {
89
+ message_id: this.host.nextMessageId(),
90
+ date: this.host.now(),
91
+ chat: this.toChat(),
92
+ text: resolvedText,
93
+ ...(entities.length ? { entities } : {}),
94
+ };
95
+
96
+ this.messages.push(message);
97
+ await this.host.dispatch(createUpdate({ channel_post: message }));
98
+
99
+ return message as Message;
100
+ }
101
+ }