@bytesbrains/pi-telegram-bridge 1.0.0 → 1.0.2

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.
@@ -0,0 +1,783 @@
1
+ /**
2
+ * pi-telegram-bridge — Helpers Tests
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
+
6
+ const helpers = await import("../helpers");
7
+
8
+ const TEST_TOKEN = "123456:test-bot-token";
9
+ const TEST_CHAT_ID = "987654321";
10
+
11
+ beforeEach(() => {
12
+ vi.stubEnv("TELEGRAM_BOT_TOKEN", TEST_TOKEN);
13
+ vi.stubEnv("TELEGRAM_CHAT_ID", TEST_CHAT_ID);
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllEnvs();
19
+ });
20
+
21
+ // ── getToken ──────────────────────────────────────────────────────
22
+
23
+ describe("getToken", () => {
24
+ it("returns the token when set", () => {
25
+ expect(helpers.getToken()).toBe(TEST_TOKEN);
26
+ });
27
+
28
+ it("throws when TELEGRAM_BOT_TOKEN is not set", () => {
29
+ vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
30
+ expect(() => helpers.getToken()).toThrow("TELEGRAM_BOT_TOKEN not set");
31
+ });
32
+
33
+ it("throws when TELEGRAM_BOT_TOKEN is undefined", () => {
34
+ vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
35
+ expect(() => helpers.getToken()).toThrow("TELEGRAM_BOT_TOKEN not set");
36
+ });
37
+ });
38
+
39
+ // ── getChatId ─────────────────────────────────────────────────────
40
+
41
+ describe("getChatId", () => {
42
+ it("returns chat id when set", () => {
43
+ expect(helpers.getChatId()).toBe(TEST_CHAT_ID);
44
+ });
45
+
46
+ it("throws when TELEGRAM_CHAT_ID is not set", () => {
47
+ vi.stubEnv("TELEGRAM_CHAT_ID", "");
48
+ expect(() => helpers.getChatId()).toThrow("TELEGRAM_CHAT_ID not set");
49
+ });
50
+ });
51
+
52
+ // ── telegramApi ───────────────────────────────────────────────────
53
+
54
+ describe("telegramApi", () => {
55
+ it("makes POST to correct URL with JSON body", async () => {
56
+ const fetchMock = vi.fn().mockResolvedValue({
57
+ ok: true,
58
+ json: async () => ({ ok: true, result: { id: 123 } }),
59
+ });
60
+ vi.stubGlobal("fetch", fetchMock);
61
+
62
+ const result = await helpers.telegramApi("getMe", { foo: "bar" });
63
+
64
+ expect(fetchMock).toHaveBeenCalledWith(
65
+ `https://api.telegram.org/bot${TEST_TOKEN}/getMe`,
66
+ {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify({ foo: "bar" }),
70
+ },
71
+ );
72
+ expect(result).toEqual({ ok: true, result: { id: 123 } });
73
+ });
74
+
75
+ it("throws on non-ok with status code", async () => {
76
+ vi.stubGlobal(
77
+ "fetch",
78
+ vi.fn().mockResolvedValue({
79
+ ok: false,
80
+ status: 401,
81
+ text: async () => "Unauthorized",
82
+ }),
83
+ );
84
+ await expect(helpers.telegramApi("getMe", {})).rejects.toThrow(
85
+ "Telegram API 401: Unauthorized",
86
+ );
87
+ });
88
+
89
+ it("throws on network error", async () => {
90
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network down")));
91
+ await expect(helpers.telegramApi("getMe", {})).rejects.toThrow("Network down");
92
+ });
93
+ });
94
+
95
+ // ── sendMsg ────────────────────────────────────────────────────────
96
+
97
+ describe("sendMsg", () => {
98
+ it("sends with Markdown parse mode and returns message_id", async () => {
99
+ vi.stubGlobal(
100
+ "fetch",
101
+ vi.fn().mockResolvedValue({
102
+ ok: true,
103
+ json: async () => ({ ok: true, result: { message_id: 42 } }),
104
+ }),
105
+ );
106
+ const mid = await helpers.sendMsg("Hello *world*");
107
+ expect(mid).toBe(42);
108
+ });
109
+
110
+ it("includes reply_markup when provided", async () => {
111
+ const fetchMock = vi.fn().mockResolvedValue({
112
+ ok: true,
113
+ json: async () => ({ ok: true, result: { message_id: 1 } }),
114
+ });
115
+ vi.stubGlobal("fetch", fetchMock);
116
+
117
+ const markup = { inline_keyboard: [[{ text: "Yes", callback_data: "yes" }]] };
118
+ await helpers.sendMsg("Q?", markup);
119
+
120
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
121
+ expect(body.reply_markup).toBe(JSON.stringify(markup));
122
+ });
123
+
124
+ it("omits reply_markup when not provided", async () => {
125
+ const fetchMock = vi.fn().mockResolvedValue({
126
+ ok: true,
127
+ json: async () => ({ ok: true, result: { message_id: 1 } }),
128
+ });
129
+ vi.stubGlobal("fetch", fetchMock);
130
+
131
+ await helpers.sendMsg("Plain");
132
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
133
+ expect(body.reply_markup).toBeUndefined();
134
+ });
135
+
136
+ it("throws on API error", async () => {
137
+ vi.stubGlobal(
138
+ "fetch",
139
+ vi.fn().mockResolvedValue({
140
+ ok: false,
141
+ status: 400,
142
+ text: async () => "chat not found",
143
+ }),
144
+ );
145
+ await expect(helpers.sendMsg("test")).rejects.toThrow(
146
+ "Telegram API 400: chat not found",
147
+ );
148
+ });
149
+ });
150
+
151
+ // ── pollReply ─────────────────────────────────────────────────────
152
+
153
+ describe("pollReply", () => {
154
+ it("returns text reply when new message arrives after sentId", async () => {
155
+ vi.stubGlobal(
156
+ "fetch",
157
+ vi
158
+ .fn()
159
+ .mockResolvedValueOnce({
160
+ ok: true,
161
+ json: async () => ({ ok: true, result: [] }),
162
+ })
163
+ .mockResolvedValueOnce({
164
+ ok: true,
165
+ json: async () => ({
166
+ ok: true,
167
+ result: [
168
+ {
169
+ update_id: 100,
170
+ message: {
171
+ message_id: 50,
172
+ chat: { id: Number(TEST_CHAT_ID) },
173
+ text: "I approve!",
174
+ },
175
+ },
176
+ ],
177
+ }),
178
+ }),
179
+ );
180
+
181
+ const reply = await helpers.pollReply(10, TEST_CHAT_ID, 60000);
182
+ expect(reply).toBe("I approve!");
183
+ });
184
+
185
+ it("returns callback_query data when button clicked", async () => {
186
+ // Mock fetch: drain + poll with callback_query + answerCallbackQuery POST
187
+ const fetchMock = vi
188
+ .fn()
189
+ // drain
190
+ .mockResolvedValueOnce({
191
+ ok: true,
192
+ json: async () => ({ ok: true, result: [] }),
193
+ })
194
+ // poll — returns callback_query
195
+ .mockResolvedValueOnce({
196
+ ok: true,
197
+ json: async () => ({
198
+ ok: true,
199
+ result: [
200
+ {
201
+ update_id: 200,
202
+ callback_query: {
203
+ id: "cb-1",
204
+ message: {
205
+ message_id: 30,
206
+ chat: { id: Number(TEST_CHAT_ID) },
207
+ },
208
+ data: "Yes, proceed",
209
+ },
210
+ },
211
+ ],
212
+ }),
213
+ })
214
+ // answerCallbackQuery POST (triggered by callback_query above)
215
+ .mockResolvedValueOnce({
216
+ ok: true,
217
+ json: async () => ({ ok: true, result: true }),
218
+ });
219
+ vi.stubGlobal("fetch", fetchMock);
220
+
221
+ const reply = await helpers.pollReply(10, TEST_CHAT_ID, 60000);
222
+ expect(reply).toBe("Yes, proceed");
223
+
224
+ // Verify answerCallbackQuery was called
225
+ const cbCall = fetchMock.mock.calls[2];
226
+ expect(cbCall[0]).toContain("answerCallbackQuery");
227
+ expect(JSON.parse(cbCall[1].body)).toEqual({
228
+ callback_query_id: "cb-1",
229
+ });
230
+ });
231
+
232
+ it("returns null on timeout (past deadline)", async () => {
233
+ const reply = await helpers.pollReply(1, TEST_CHAT_ID, -1);
234
+ expect(reply).toBeNull();
235
+ });
236
+
237
+ it("ignores messages with message_id <= sentId", async () => {
238
+ vi.stubGlobal(
239
+ "fetch",
240
+ vi
241
+ .fn()
242
+ .mockResolvedValueOnce({
243
+ ok: true,
244
+ json: async () => ({ ok: true, result: [] }),
245
+ })
246
+ .mockResolvedValue({
247
+ ok: true,
248
+ json: async () => ({
249
+ ok: true,
250
+ result: [
251
+ {
252
+ update_id: 1,
253
+ message: {
254
+ message_id: 5,
255
+ chat: { id: Number(TEST_CHAT_ID) },
256
+ text: "old",
257
+ },
258
+ },
259
+ ],
260
+ }),
261
+ }),
262
+ );
263
+
264
+ // sentId=10, message_id=5 → ignored → loops until timeout (-1 = immediate)
265
+ const reply = await helpers.pollReply(10, TEST_CHAT_ID, -1);
266
+ expect(reply).toBeNull();
267
+ });
268
+
269
+ it("survives drain errors gracefully", async () => {
270
+ vi.stubGlobal(
271
+ "fetch",
272
+ vi
273
+ .fn()
274
+ .mockRejectedValueOnce(new Error("drain fail"))
275
+ .mockResolvedValueOnce({
276
+ ok: true,
277
+ json: async () => ({
278
+ ok: true,
279
+ result: [
280
+ {
281
+ update_id: 300,
282
+ message: {
283
+ message_id: 60,
284
+ chat: { id: Number(TEST_CHAT_ID) },
285
+ text: "still works",
286
+ },
287
+ },
288
+ ],
289
+ }),
290
+ }),
291
+ );
292
+
293
+ const reply = await helpers.pollReply(10, TEST_CHAT_ID, 60000);
294
+ expect(reply).toBe("still works");
295
+ });
296
+
297
+ it("retries on non-ok poll responses", async () => {
298
+ vi.stubGlobal(
299
+ "fetch",
300
+ vi
301
+ .fn()
302
+ .mockResolvedValueOnce({
303
+ ok: true,
304
+ json: async () => ({ ok: true, result: [] }),
305
+ })
306
+ .mockResolvedValueOnce({ ok: false, status: 500 })
307
+ .mockResolvedValueOnce({
308
+ ok: true,
309
+ json: async () => ({
310
+ ok: true,
311
+ result: [
312
+ {
313
+ update_id: 999,
314
+ message: {
315
+ message_id: 70,
316
+ chat: { id: Number(TEST_CHAT_ID) },
317
+ text: "recovered",
318
+ },
319
+ },
320
+ ],
321
+ }),
322
+ }),
323
+ );
324
+
325
+ const reply = await helpers.pollReply(10, TEST_CHAT_ID, 60000);
326
+ expect(reply).toBe("recovered");
327
+ });
328
+ });
329
+
330
+ // ── getBotId ──────────────────────────────────────────────────────
331
+
332
+ describe("getBotId", () => {
333
+ it("returns bot ID from getMe", async () => {
334
+ vi.stubGlobal(
335
+ "fetch",
336
+ vi.fn().mockResolvedValue({
337
+ ok: true,
338
+ json: async () => ({ ok: true, result: { id: 123456 } }),
339
+ }),
340
+ );
341
+ const id = await helpers.getBotId();
342
+ expect(id).toBe(123456);
343
+ });
344
+
345
+ it("returns null on API error", async () => {
346
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fail")));
347
+ const id = await helpers.getBotId();
348
+ expect(id).toBeNull();
349
+ });
350
+ });
351
+
352
+ // ── seedLastUpdateId ──────────────────────────────────────────────
353
+
354
+ describe("seedLastUpdateId", () => {
355
+ it("returns existing when non-zero", async () => {
356
+ expect(await helpers.seedLastUpdateId(42)).toBe(42);
357
+ });
358
+
359
+ it("fetches latest update_id when 0", async () => {
360
+ vi.stubGlobal(
361
+ "fetch",
362
+ vi.fn().mockResolvedValue({
363
+ ok: true,
364
+ json: async () => ({
365
+ ok: true,
366
+ result: [{ update_id: 5 }, { update_id: 15 }],
367
+ }),
368
+ }),
369
+ );
370
+ expect(await helpers.seedLastUpdateId(0)).toBe(15);
371
+ });
372
+
373
+ it("returns 0 when fetch fails", async () => {
374
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fail")));
375
+ expect(await helpers.seedLastUpdateId(0)).toBe(0);
376
+ });
377
+
378
+ it("returns 0 when result is empty", async () => {
379
+ vi.stubGlobal(
380
+ "fetch",
381
+ vi.fn().mockResolvedValue({
382
+ ok: true,
383
+ json: async () => ({ ok: true, result: [] }),
384
+ }),
385
+ );
386
+ expect(await helpers.seedLastUpdateId(0)).toBe(0);
387
+ });
388
+
389
+ it("returns 0 on non-ok response", async () => {
390
+ vi.stubGlobal(
391
+ "fetch",
392
+ vi.fn().mockResolvedValue({ ok: false }),
393
+ );
394
+ expect(await helpers.seedLastUpdateId(0)).toBe(0);
395
+ });
396
+ });
397
+
398
+ // ── pollUpdates ───────────────────────────────────────────────────
399
+
400
+ describe("pollUpdates", () => {
401
+ it("calls onMessage for valid text from configured chat", async () => {
402
+ const controller = new AbortController();
403
+ const onMessage = vi.fn();
404
+ const onUpdateId = vi.fn();
405
+
406
+ let calls = 0;
407
+ vi.stubGlobal(
408
+ "fetch",
409
+ vi.fn().mockImplementation(() => {
410
+ calls++;
411
+ if (calls === 1) {
412
+ return Promise.resolve({
413
+ ok: true,
414
+ json: async () => ({
415
+ ok: true,
416
+ result: [
417
+ {
418
+ update_id: 100,
419
+ message: {
420
+ message_id: 1,
421
+ chat: { id: Number(TEST_CHAT_ID) },
422
+ from: { id: 999, is_bot: false },
423
+ text: "Hello agent!",
424
+ },
425
+ },
426
+ ],
427
+ }),
428
+ });
429
+ }
430
+ controller.abort();
431
+ return Promise.reject(
432
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
433
+ );
434
+ }),
435
+ );
436
+
437
+ // pollUpdates has 5s delay between polls — this test takes ~5s
438
+ await helpers.pollUpdates(
439
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
440
+ controller.signal, onMessage, onUpdateId,
441
+ );
442
+
443
+ expect(onMessage).toHaveBeenCalledWith("Hello agent!");
444
+ expect(onUpdateId).toHaveBeenCalledWith(100);
445
+ }, 15000);
446
+
447
+ it("ignores messages from the bot itself", async () => {
448
+ const controller = new AbortController();
449
+ const onMessage = vi.fn();
450
+ const onUpdateId = vi.fn();
451
+
452
+ let calls = 0;
453
+ vi.stubGlobal(
454
+ "fetch",
455
+ vi.fn().mockImplementation(() => {
456
+ calls++;
457
+ if (calls === 1) {
458
+ return Promise.resolve({
459
+ ok: true,
460
+ json: async () => ({
461
+ ok: true,
462
+ result: [
463
+ {
464
+ update_id: 200,
465
+ message: {
466
+ message_id: 2,
467
+ chat: { id: Number(TEST_CHAT_ID) },
468
+ from: { id: 12345, is_bot: true },
469
+ text: "Self message",
470
+ },
471
+ },
472
+ ],
473
+ }),
474
+ });
475
+ }
476
+ controller.abort();
477
+ return Promise.reject(
478
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
479
+ );
480
+ }),
481
+ );
482
+
483
+ await helpers.pollUpdates(
484
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
485
+ controller.signal, onMessage, onUpdateId,
486
+ );
487
+
488
+ expect(onMessage).not.toHaveBeenCalled();
489
+ expect(onUpdateId).toHaveBeenCalledWith(200);
490
+ }, 15000);
491
+
492
+ it("ignores messages from other chats", async () => {
493
+ const controller = new AbortController();
494
+ const onMessage = vi.fn();
495
+ const onUpdateId = vi.fn();
496
+
497
+ let calls = 0;
498
+ vi.stubGlobal(
499
+ "fetch",
500
+ vi.fn().mockImplementation(() => {
501
+ calls++;
502
+ if (calls === 1) {
503
+ return Promise.resolve({
504
+ ok: true,
505
+ json: async () => ({
506
+ ok: true,
507
+ result: [
508
+ {
509
+ update_id: 300,
510
+ message: {
511
+ message_id: 3,
512
+ chat: { id: 11111 },
513
+ from: { id: 999, is_bot: false },
514
+ text: "Wrong chat",
515
+ },
516
+ },
517
+ ],
518
+ }),
519
+ });
520
+ }
521
+ controller.abort();
522
+ return Promise.reject(
523
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
524
+ );
525
+ }),
526
+ );
527
+
528
+ await helpers.pollUpdates(
529
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
530
+ controller.signal, onMessage, onUpdateId,
531
+ );
532
+
533
+ expect(onMessage).not.toHaveBeenCalled();
534
+ expect(onUpdateId).toHaveBeenCalledWith(300);
535
+ }, 15000);
536
+
537
+ it("skips callback_query updates", async () => {
538
+ const controller = new AbortController();
539
+ const onMessage = vi.fn();
540
+ const onUpdateId = vi.fn();
541
+
542
+ let calls = 0;
543
+ vi.stubGlobal(
544
+ "fetch",
545
+ vi.fn().mockImplementation(() => {
546
+ calls++;
547
+ if (calls === 1) {
548
+ return Promise.resolve({
549
+ ok: true,
550
+ json: async () => ({
551
+ ok: true,
552
+ result: [
553
+ {
554
+ update_id: 400,
555
+ callback_query: { id: "cb-x", data: "yes" },
556
+ },
557
+ ],
558
+ }),
559
+ });
560
+ }
561
+ controller.abort();
562
+ return Promise.reject(
563
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
564
+ );
565
+ }),
566
+ );
567
+
568
+ await helpers.pollUpdates(
569
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
570
+ controller.signal, onMessage, onUpdateId,
571
+ );
572
+
573
+ expect(onMessage).not.toHaveBeenCalled();
574
+ expect(onUpdateId).toHaveBeenCalledWith(400);
575
+ }, 15000);
576
+
577
+ it("retries on non-ok responses", async () => {
578
+ const controller = new AbortController();
579
+ const onMessage = vi.fn();
580
+ const onUpdateId = vi.fn();
581
+
582
+ let calls = 0;
583
+ vi.stubGlobal(
584
+ "fetch",
585
+ vi.fn().mockImplementation(() => {
586
+ calls++;
587
+ if (calls === 1) {
588
+ return Promise.resolve({ ok: false, status: 500 });
589
+ }
590
+ if (calls === 2) {
591
+ return Promise.resolve({
592
+ ok: true,
593
+ json: async () => ({
594
+ ok: true,
595
+ result: [
596
+ {
597
+ update_id: 500,
598
+ message: {
599
+ message_id: 5,
600
+ chat: { id: Number(TEST_CHAT_ID) },
601
+ from: { id: 999, is_bot: false },
602
+ text: "recovered",
603
+ },
604
+ },
605
+ ],
606
+ }),
607
+ });
608
+ }
609
+ controller.abort();
610
+ return Promise.reject(
611
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
612
+ );
613
+ }),
614
+ );
615
+
616
+ await helpers.pollUpdates(
617
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
618
+ controller.signal, onMessage, onUpdateId,
619
+ );
620
+
621
+ expect(onMessage).toHaveBeenCalledWith("recovered");
622
+ }, 20000);
623
+
624
+ it("handles network errors with retry", async () => {
625
+ const controller = new AbortController();
626
+ const onMessage = vi.fn();
627
+ const onUpdateId = vi.fn();
628
+
629
+ let calls = 0;
630
+ vi.stubGlobal(
631
+ "fetch",
632
+ vi.fn().mockImplementation(() => {
633
+ calls++;
634
+ if (calls <= 2) return Promise.reject(new Error("ECONNRESET"));
635
+ if (calls === 3) {
636
+ return Promise.resolve({
637
+ ok: true,
638
+ json: async () => ({
639
+ ok: true,
640
+ result: [
641
+ {
642
+ update_id: 600,
643
+ message: {
644
+ message_id: 6,
645
+ chat: { id: Number(TEST_CHAT_ID) },
646
+ from: { id: 999, is_bot: false },
647
+ text: "eventually",
648
+ },
649
+ },
650
+ ],
651
+ }),
652
+ });
653
+ }
654
+ controller.abort();
655
+ return Promise.reject(
656
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
657
+ );
658
+ }),
659
+ );
660
+
661
+ await helpers.pollUpdates(
662
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
663
+ controller.signal, onMessage, onUpdateId,
664
+ );
665
+
666
+ expect(onMessage).toHaveBeenCalledWith("eventually");
667
+ }, 25000);
668
+
669
+ it("stops gracefully when aborted immediately", async () => {
670
+ const controller = new AbortController();
671
+ controller.abort();
672
+ const onMessage = vi.fn();
673
+ const onUpdateId = vi.fn();
674
+
675
+ vi.stubGlobal(
676
+ "fetch",
677
+ vi.fn().mockRejectedValue(
678
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
679
+ ),
680
+ );
681
+
682
+ await helpers.pollUpdates(
683
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
684
+ controller.signal, onMessage, onUpdateId,
685
+ );
686
+
687
+ expect(onMessage).not.toHaveBeenCalled();
688
+ });
689
+
690
+ it("handles !data.ok with retry", async () => {
691
+ const controller = new AbortController();
692
+ const onMessage = vi.fn();
693
+ const onUpdateId = vi.fn();
694
+
695
+ let calls = 0;
696
+ vi.stubGlobal(
697
+ "fetch",
698
+ vi.fn().mockImplementation(() => {
699
+ calls++;
700
+ if (calls === 1) {
701
+ return Promise.resolve({
702
+ ok: true,
703
+ json: async () => ({ ok: false, result: [] }),
704
+ });
705
+ }
706
+ if (calls === 2) {
707
+ return Promise.resolve({
708
+ ok: true,
709
+ json: async () => ({
710
+ ok: true,
711
+ result: [
712
+ {
713
+ update_id: 700,
714
+ message: {
715
+ message_id: 7,
716
+ chat: { id: Number(TEST_CHAT_ID) },
717
+ from: { id: 999, is_bot: false },
718
+ text: "after not-ok",
719
+ },
720
+ },
721
+ ],
722
+ }),
723
+ });
724
+ }
725
+ controller.abort();
726
+ return Promise.reject(
727
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
728
+ );
729
+ }),
730
+ );
731
+
732
+ await helpers.pollUpdates(
733
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
734
+ controller.signal, onMessage, onUpdateId,
735
+ );
736
+
737
+ expect(onMessage).toHaveBeenCalledWith("after not-ok");
738
+ }, 20000);
739
+
740
+ it("ignores messages without text field", async () => {
741
+ const controller = new AbortController();
742
+ const onMessage = vi.fn();
743
+ const onUpdateId = vi.fn();
744
+
745
+ let calls = 0;
746
+ vi.stubGlobal(
747
+ "fetch",
748
+ vi.fn().mockImplementation(() => {
749
+ calls++;
750
+ if (calls === 1) {
751
+ return Promise.resolve({
752
+ ok: true,
753
+ json: async () => ({
754
+ ok: true,
755
+ result: [
756
+ {
757
+ update_id: 800,
758
+ message: {
759
+ message_id: 8,
760
+ chat: { id: Number(TEST_CHAT_ID) },
761
+ from: { id: 999, is_bot: false },
762
+ // no text — e.g., photo, sticker
763
+ },
764
+ },
765
+ ],
766
+ }),
767
+ });
768
+ }
769
+ controller.abort();
770
+ return Promise.reject(
771
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
772
+ );
773
+ }),
774
+ );
775
+
776
+ await helpers.pollUpdates(
777
+ TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
778
+ controller.signal, onMessage, onUpdateId,
779
+ );
780
+
781
+ expect(onMessage).not.toHaveBeenCalled();
782
+ }, 15000);
783
+ });