@openclaw/bluebubbles 2026.1.29

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,809 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
4
+ import type { BlueBubblesSendTarget } from "./types.js";
5
+
6
+ vi.mock("./accounts.js", () => ({
7
+ resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
8
+ const config = cfg?.channels?.bluebubbles ?? {};
9
+ return {
10
+ accountId: accountId ?? "default",
11
+ enabled: config.enabled !== false,
12
+ configured: Boolean(config.serverUrl && config.password),
13
+ config,
14
+ };
15
+ }),
16
+ }));
17
+
18
+ const mockFetch = vi.fn();
19
+
20
+ describe("send", () => {
21
+ beforeEach(() => {
22
+ vi.stubGlobal("fetch", mockFetch);
23
+ mockFetch.mockReset();
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ describe("resolveChatGuidForTarget", () => {
31
+ it("returns chatGuid directly for chat_guid target", async () => {
32
+ const target: BlueBubblesSendTarget = {
33
+ kind: "chat_guid",
34
+ chatGuid: "iMessage;-;+15551234567",
35
+ };
36
+ const result = await resolveChatGuidForTarget({
37
+ baseUrl: "http://localhost:1234",
38
+ password: "test",
39
+ target,
40
+ });
41
+ expect(result).toBe("iMessage;-;+15551234567");
42
+ expect(mockFetch).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it("queries chats to resolve chat_id target", async () => {
46
+ mockFetch.mockResolvedValueOnce({
47
+ ok: true,
48
+ json: () =>
49
+ Promise.resolve({
50
+ data: [
51
+ { id: 123, guid: "iMessage;-;chat123", participants: [] },
52
+ { id: 456, guid: "iMessage;-;chat456", participants: [] },
53
+ ],
54
+ }),
55
+ });
56
+
57
+ const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 };
58
+ const result = await resolveChatGuidForTarget({
59
+ baseUrl: "http://localhost:1234",
60
+ password: "test",
61
+ target,
62
+ });
63
+
64
+ expect(result).toBe("iMessage;-;chat456");
65
+ expect(mockFetch).toHaveBeenCalledWith(
66
+ expect.stringContaining("/api/v1/chat/query"),
67
+ expect.objectContaining({ method: "POST" }),
68
+ );
69
+ });
70
+
71
+ it("queries chats to resolve chat_identifier target", async () => {
72
+ mockFetch.mockResolvedValueOnce({
73
+ ok: true,
74
+ json: () =>
75
+ Promise.resolve({
76
+ data: [
77
+ {
78
+ identifier: "chat123@group.imessage",
79
+ guid: "iMessage;-;chat123",
80
+ participants: [],
81
+ },
82
+ ],
83
+ }),
84
+ });
85
+
86
+ const target: BlueBubblesSendTarget = {
87
+ kind: "chat_identifier",
88
+ chatIdentifier: "chat123@group.imessage",
89
+ };
90
+ const result = await resolveChatGuidForTarget({
91
+ baseUrl: "http://localhost:1234",
92
+ password: "test",
93
+ target,
94
+ });
95
+
96
+ expect(result).toBe("iMessage;-;chat123");
97
+ });
98
+
99
+ it("matches chat_identifier against the 3rd component of chat GUID", async () => {
100
+ mockFetch.mockResolvedValueOnce({
101
+ ok: true,
102
+ json: () =>
103
+ Promise.resolve({
104
+ data: [
105
+ {
106
+ guid: "iMessage;+;chat660250192681427962",
107
+ participants: [],
108
+ },
109
+ ],
110
+ }),
111
+ });
112
+
113
+ const target: BlueBubblesSendTarget = {
114
+ kind: "chat_identifier",
115
+ chatIdentifier: "chat660250192681427962",
116
+ };
117
+ const result = await resolveChatGuidForTarget({
118
+ baseUrl: "http://localhost:1234",
119
+ password: "test",
120
+ target,
121
+ });
122
+
123
+ expect(result).toBe("iMessage;+;chat660250192681427962");
124
+ });
125
+
126
+ it("resolves handle target by matching participant", async () => {
127
+ mockFetch.mockResolvedValueOnce({
128
+ ok: true,
129
+ json: () =>
130
+ Promise.resolve({
131
+ data: [
132
+ {
133
+ guid: "iMessage;-;+15559999999",
134
+ participants: [{ address: "+15559999999" }],
135
+ },
136
+ {
137
+ guid: "iMessage;-;+15551234567",
138
+ participants: [{ address: "+15551234567" }],
139
+ },
140
+ ],
141
+ }),
142
+ });
143
+
144
+ const target: BlueBubblesSendTarget = {
145
+ kind: "handle",
146
+ address: "+15551234567",
147
+ service: "imessage",
148
+ };
149
+ const result = await resolveChatGuidForTarget({
150
+ baseUrl: "http://localhost:1234",
151
+ password: "test",
152
+ target,
153
+ });
154
+
155
+ expect(result).toBe("iMessage;-;+15551234567");
156
+ });
157
+
158
+ it("prefers direct chat guid when handle also appears in a group chat", async () => {
159
+ mockFetch.mockResolvedValueOnce({
160
+ ok: true,
161
+ json: () =>
162
+ Promise.resolve({
163
+ data: [
164
+ {
165
+ guid: "iMessage;+;group-123",
166
+ participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
167
+ },
168
+ {
169
+ guid: "iMessage;-;+15551234567",
170
+ participants: [{ address: "+15551234567" }],
171
+ },
172
+ ],
173
+ }),
174
+ });
175
+
176
+ const target: BlueBubblesSendTarget = {
177
+ kind: "handle",
178
+ address: "+15551234567",
179
+ service: "imessage",
180
+ };
181
+ const result = await resolveChatGuidForTarget({
182
+ baseUrl: "http://localhost:1234",
183
+ password: "test",
184
+ target,
185
+ });
186
+
187
+ expect(result).toBe("iMessage;-;+15551234567");
188
+ });
189
+
190
+ it("returns null when handle only exists in group chat (not DM)", async () => {
191
+ // This is the critical fix: if a phone number only exists as a participant in a group chat
192
+ // (no direct DM chat), we should NOT send to that group. Return null instead.
193
+ mockFetch
194
+ .mockResolvedValueOnce({
195
+ ok: true,
196
+ json: () =>
197
+ Promise.resolve({
198
+ data: [
199
+ {
200
+ guid: "iMessage;+;group-the-council",
201
+ participants: [
202
+ { address: "+12622102921" },
203
+ { address: "+15550001111" },
204
+ { address: "+15550002222" },
205
+ ],
206
+ },
207
+ ],
208
+ }),
209
+ })
210
+ // Empty second page to stop pagination
211
+ .mockResolvedValueOnce({
212
+ ok: true,
213
+ json: () => Promise.resolve({ data: [] }),
214
+ });
215
+
216
+ const target: BlueBubblesSendTarget = {
217
+ kind: "handle",
218
+ address: "+12622102921",
219
+ service: "imessage",
220
+ };
221
+ const result = await resolveChatGuidForTarget({
222
+ baseUrl: "http://localhost:1234",
223
+ password: "test",
224
+ target,
225
+ });
226
+
227
+ // Should return null, NOT the group chat GUID
228
+ expect(result).toBeNull();
229
+ });
230
+
231
+ it("returns null when chat not found", async () => {
232
+ mockFetch.mockResolvedValueOnce({
233
+ ok: true,
234
+ json: () => Promise.resolve({ data: [] }),
235
+ });
236
+
237
+ const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 };
238
+ const result = await resolveChatGuidForTarget({
239
+ baseUrl: "http://localhost:1234",
240
+ password: "test",
241
+ target,
242
+ });
243
+
244
+ expect(result).toBeNull();
245
+ });
246
+
247
+ it("handles API error gracefully", async () => {
248
+ mockFetch.mockResolvedValueOnce({
249
+ ok: false,
250
+ status: 500,
251
+ });
252
+
253
+ const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 };
254
+ const result = await resolveChatGuidForTarget({
255
+ baseUrl: "http://localhost:1234",
256
+ password: "test",
257
+ target,
258
+ });
259
+
260
+ expect(result).toBeNull();
261
+ });
262
+
263
+ it("paginates through chats to find match", async () => {
264
+ mockFetch
265
+ .mockResolvedValueOnce({
266
+ ok: true,
267
+ json: () =>
268
+ Promise.resolve({
269
+ data: Array(500)
270
+ .fill(null)
271
+ .map((_, i) => ({
272
+ id: i,
273
+ guid: `chat-${i}`,
274
+ participants: [],
275
+ })),
276
+ }),
277
+ })
278
+ .mockResolvedValueOnce({
279
+ ok: true,
280
+ json: () =>
281
+ Promise.resolve({
282
+ data: [{ id: 555, guid: "found-chat", participants: [] }],
283
+ }),
284
+ });
285
+
286
+ const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 };
287
+ const result = await resolveChatGuidForTarget({
288
+ baseUrl: "http://localhost:1234",
289
+ password: "test",
290
+ target,
291
+ });
292
+
293
+ expect(result).toBe("found-chat");
294
+ expect(mockFetch).toHaveBeenCalledTimes(2);
295
+ });
296
+
297
+ it("normalizes handle addresses for matching", async () => {
298
+ mockFetch.mockResolvedValueOnce({
299
+ ok: true,
300
+ json: () =>
301
+ Promise.resolve({
302
+ data: [
303
+ {
304
+ guid: "iMessage;-;test@example.com",
305
+ participants: [{ address: "Test@Example.COM" }],
306
+ },
307
+ ],
308
+ }),
309
+ });
310
+
311
+ const target: BlueBubblesSendTarget = {
312
+ kind: "handle",
313
+ address: "test@example.com",
314
+ service: "auto",
315
+ };
316
+ const result = await resolveChatGuidForTarget({
317
+ baseUrl: "http://localhost:1234",
318
+ password: "test",
319
+ target,
320
+ });
321
+
322
+ expect(result).toBe("iMessage;-;test@example.com");
323
+ });
324
+
325
+ it("extracts guid from various response formats", async () => {
326
+ mockFetch.mockResolvedValueOnce({
327
+ ok: true,
328
+ json: () =>
329
+ Promise.resolve({
330
+ data: [
331
+ {
332
+ chatGuid: "format1-guid",
333
+ id: 100,
334
+ participants: [],
335
+ },
336
+ ],
337
+ }),
338
+ });
339
+
340
+ const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 };
341
+ const result = await resolveChatGuidForTarget({
342
+ baseUrl: "http://localhost:1234",
343
+ password: "test",
344
+ target,
345
+ });
346
+
347
+ expect(result).toBe("format1-guid");
348
+ });
349
+ });
350
+
351
+ describe("sendMessageBlueBubbles", () => {
352
+ beforeEach(() => {
353
+ mockFetch.mockReset();
354
+ });
355
+
356
+ it("throws when text is empty", async () => {
357
+ await expect(
358
+ sendMessageBlueBubbles("+15551234567", "", {
359
+ serverUrl: "http://localhost:1234",
360
+ password: "test",
361
+ }),
362
+ ).rejects.toThrow("requires text");
363
+ });
364
+
365
+ it("throws when text is whitespace only", async () => {
366
+ await expect(
367
+ sendMessageBlueBubbles("+15551234567", " ", {
368
+ serverUrl: "http://localhost:1234",
369
+ password: "test",
370
+ }),
371
+ ).rejects.toThrow("requires text");
372
+ });
373
+
374
+ it("throws when serverUrl is missing", async () => {
375
+ await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
376
+ "serverUrl is required",
377
+ );
378
+ });
379
+
380
+ it("throws when password is missing", async () => {
381
+ await expect(
382
+ sendMessageBlueBubbles("+15551234567", "Hello", {
383
+ serverUrl: "http://localhost:1234",
384
+ }),
385
+ ).rejects.toThrow("password is required");
386
+ });
387
+
388
+ it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
389
+ mockFetch.mockResolvedValue({
390
+ ok: true,
391
+ json: () => Promise.resolve({ data: [] }),
392
+ });
393
+
394
+ await expect(
395
+ sendMessageBlueBubbles("chat_id:999", "Hello", {
396
+ serverUrl: "http://localhost:1234",
397
+ password: "test",
398
+ }),
399
+ ).rejects.toThrow("chatGuid not found");
400
+ });
401
+
402
+ it("sends message successfully", async () => {
403
+ mockFetch
404
+ .mockResolvedValueOnce({
405
+ ok: true,
406
+ json: () =>
407
+ Promise.resolve({
408
+ data: [
409
+ {
410
+ guid: "iMessage;-;+15551234567",
411
+ participants: [{ address: "+15551234567" }],
412
+ },
413
+ ],
414
+ }),
415
+ })
416
+ .mockResolvedValueOnce({
417
+ ok: true,
418
+ text: () =>
419
+ Promise.resolve(
420
+ JSON.stringify({
421
+ data: { guid: "msg-uuid-123" },
422
+ }),
423
+ ),
424
+ });
425
+
426
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
427
+ serverUrl: "http://localhost:1234",
428
+ password: "test",
429
+ });
430
+
431
+ expect(result.messageId).toBe("msg-uuid-123");
432
+ expect(mockFetch).toHaveBeenCalledTimes(2);
433
+
434
+ const sendCall = mockFetch.mock.calls[1];
435
+ expect(sendCall[0]).toContain("/api/v1/message/text");
436
+ const body = JSON.parse(sendCall[1].body);
437
+ expect(body.chatGuid).toBe("iMessage;-;+15551234567");
438
+ expect(body.message).toBe("Hello world!");
439
+ expect(body.method).toBeUndefined();
440
+ });
441
+
442
+ it("creates a new chat when handle target is missing", async () => {
443
+ mockFetch
444
+ .mockResolvedValueOnce({
445
+ ok: true,
446
+ json: () => Promise.resolve({ data: [] }),
447
+ })
448
+ .mockResolvedValueOnce({
449
+ ok: true,
450
+ text: () =>
451
+ Promise.resolve(
452
+ JSON.stringify({
453
+ data: { guid: "new-msg-guid" },
454
+ }),
455
+ ),
456
+ });
457
+
458
+ const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
459
+ serverUrl: "http://localhost:1234",
460
+ password: "test",
461
+ });
462
+
463
+ expect(result.messageId).toBe("new-msg-guid");
464
+ expect(mockFetch).toHaveBeenCalledTimes(2);
465
+
466
+ const createCall = mockFetch.mock.calls[1];
467
+ expect(createCall[0]).toContain("/api/v1/chat/new");
468
+ const body = JSON.parse(createCall[1].body);
469
+ expect(body.addresses).toEqual(["+15550009999"]);
470
+ expect(body.message).toBe("Hello new chat");
471
+ });
472
+
473
+ it("throws when creating a new chat requires Private API", async () => {
474
+ mockFetch
475
+ .mockResolvedValueOnce({
476
+ ok: true,
477
+ json: () => Promise.resolve({ data: [] }),
478
+ })
479
+ .mockResolvedValueOnce({
480
+ ok: false,
481
+ status: 403,
482
+ text: () => Promise.resolve("Private API not enabled"),
483
+ });
484
+
485
+ await expect(
486
+ sendMessageBlueBubbles("+15550008888", "Hello", {
487
+ serverUrl: "http://localhost:1234",
488
+ password: "test",
489
+ }),
490
+ ).rejects.toThrow("Private API must be enabled");
491
+ });
492
+
493
+ it("uses private-api when reply metadata is present", async () => {
494
+ mockFetch
495
+ .mockResolvedValueOnce({
496
+ ok: true,
497
+ json: () =>
498
+ Promise.resolve({
499
+ data: [
500
+ {
501
+ guid: "iMessage;-;+15551234567",
502
+ participants: [{ address: "+15551234567" }],
503
+ },
504
+ ],
505
+ }),
506
+ })
507
+ .mockResolvedValueOnce({
508
+ ok: true,
509
+ text: () =>
510
+ Promise.resolve(
511
+ JSON.stringify({
512
+ data: { guid: "msg-uuid-124" },
513
+ }),
514
+ ),
515
+ });
516
+
517
+ const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
518
+ serverUrl: "http://localhost:1234",
519
+ password: "test",
520
+ replyToMessageGuid: "reply-guid-123",
521
+ replyToPartIndex: 1,
522
+ });
523
+
524
+ expect(result.messageId).toBe("msg-uuid-124");
525
+ expect(mockFetch).toHaveBeenCalledTimes(2);
526
+
527
+ const sendCall = mockFetch.mock.calls[1];
528
+ const body = JSON.parse(sendCall[1].body);
529
+ expect(body.method).toBe("private-api");
530
+ expect(body.selectedMessageGuid).toBe("reply-guid-123");
531
+ expect(body.partIndex).toBe(1);
532
+ });
533
+
534
+ it("normalizes effect names and uses private-api for effects", async () => {
535
+ mockFetch
536
+ .mockResolvedValueOnce({
537
+ ok: true,
538
+ json: () =>
539
+ Promise.resolve({
540
+ data: [
541
+ {
542
+ guid: "iMessage;-;+15551234567",
543
+ participants: [{ address: "+15551234567" }],
544
+ },
545
+ ],
546
+ }),
547
+ })
548
+ .mockResolvedValueOnce({
549
+ ok: true,
550
+ text: () =>
551
+ Promise.resolve(
552
+ JSON.stringify({
553
+ data: { guid: "msg-uuid-125" },
554
+ }),
555
+ ),
556
+ });
557
+
558
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
559
+ serverUrl: "http://localhost:1234",
560
+ password: "test",
561
+ effectId: "invisible ink",
562
+ });
563
+
564
+ expect(result.messageId).toBe("msg-uuid-125");
565
+ expect(mockFetch).toHaveBeenCalledTimes(2);
566
+
567
+ const sendCall = mockFetch.mock.calls[1];
568
+ const body = JSON.parse(sendCall[1].body);
569
+ expect(body.method).toBe("private-api");
570
+ expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
571
+ });
572
+
573
+ it("sends message with chat_guid target directly", async () => {
574
+ mockFetch.mockResolvedValueOnce({
575
+ ok: true,
576
+ text: () =>
577
+ Promise.resolve(
578
+ JSON.stringify({
579
+ data: { messageId: "direct-msg-123" },
580
+ }),
581
+ ),
582
+ });
583
+
584
+ const result = await sendMessageBlueBubbles(
585
+ "chat_guid:iMessage;-;direct-chat",
586
+ "Direct message",
587
+ {
588
+ serverUrl: "http://localhost:1234",
589
+ password: "test",
590
+ },
591
+ );
592
+
593
+ expect(result.messageId).toBe("direct-msg-123");
594
+ expect(mockFetch).toHaveBeenCalledTimes(1);
595
+ });
596
+
597
+ it("handles send failure", async () => {
598
+ mockFetch
599
+ .mockResolvedValueOnce({
600
+ ok: true,
601
+ json: () =>
602
+ Promise.resolve({
603
+ data: [
604
+ {
605
+ guid: "iMessage;-;+15551234567",
606
+ participants: [{ address: "+15551234567" }],
607
+ },
608
+ ],
609
+ }),
610
+ })
611
+ .mockResolvedValueOnce({
612
+ ok: false,
613
+ status: 500,
614
+ text: () => Promise.resolve("Internal server error"),
615
+ });
616
+
617
+ await expect(
618
+ sendMessageBlueBubbles("+15551234567", "Hello", {
619
+ serverUrl: "http://localhost:1234",
620
+ password: "test",
621
+ }),
622
+ ).rejects.toThrow("send failed (500)");
623
+ });
624
+
625
+ it("handles empty response body", async () => {
626
+ mockFetch
627
+ .mockResolvedValueOnce({
628
+ ok: true,
629
+ json: () =>
630
+ Promise.resolve({
631
+ data: [
632
+ {
633
+ guid: "iMessage;-;+15551234567",
634
+ participants: [{ address: "+15551234567" }],
635
+ },
636
+ ],
637
+ }),
638
+ })
639
+ .mockResolvedValueOnce({
640
+ ok: true,
641
+ text: () => Promise.resolve(""),
642
+ });
643
+
644
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
645
+ serverUrl: "http://localhost:1234",
646
+ password: "test",
647
+ });
648
+
649
+ expect(result.messageId).toBe("ok");
650
+ });
651
+
652
+ it("handles invalid JSON response body", async () => {
653
+ mockFetch
654
+ .mockResolvedValueOnce({
655
+ ok: true,
656
+ json: () =>
657
+ Promise.resolve({
658
+ data: [
659
+ {
660
+ guid: "iMessage;-;+15551234567",
661
+ participants: [{ address: "+15551234567" }],
662
+ },
663
+ ],
664
+ }),
665
+ })
666
+ .mockResolvedValueOnce({
667
+ ok: true,
668
+ text: () => Promise.resolve("not valid json"),
669
+ });
670
+
671
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
672
+ serverUrl: "http://localhost:1234",
673
+ password: "test",
674
+ });
675
+
676
+ expect(result.messageId).toBe("ok");
677
+ });
678
+
679
+ it("extracts messageId from various response formats", async () => {
680
+ mockFetch
681
+ .mockResolvedValueOnce({
682
+ ok: true,
683
+ json: () =>
684
+ Promise.resolve({
685
+ data: [
686
+ {
687
+ guid: "iMessage;-;+15551234567",
688
+ participants: [{ address: "+15551234567" }],
689
+ },
690
+ ],
691
+ }),
692
+ })
693
+ .mockResolvedValueOnce({
694
+ ok: true,
695
+ text: () =>
696
+ Promise.resolve(
697
+ JSON.stringify({
698
+ id: "numeric-id-456",
699
+ }),
700
+ ),
701
+ });
702
+
703
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
704
+ serverUrl: "http://localhost:1234",
705
+ password: "test",
706
+ });
707
+
708
+ expect(result.messageId).toBe("numeric-id-456");
709
+ });
710
+
711
+ it("extracts messageGuid from response payload", async () => {
712
+ mockFetch
713
+ .mockResolvedValueOnce({
714
+ ok: true,
715
+ json: () =>
716
+ Promise.resolve({
717
+ data: [
718
+ {
719
+ guid: "iMessage;-;+15551234567",
720
+ participants: [{ address: "+15551234567" }],
721
+ },
722
+ ],
723
+ }),
724
+ })
725
+ .mockResolvedValueOnce({
726
+ ok: true,
727
+ text: () =>
728
+ Promise.resolve(
729
+ JSON.stringify({
730
+ data: { messageGuid: "msg-guid-789" },
731
+ }),
732
+ ),
733
+ });
734
+
735
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
736
+ serverUrl: "http://localhost:1234",
737
+ password: "test",
738
+ });
739
+
740
+ expect(result.messageId).toBe("msg-guid-789");
741
+ });
742
+
743
+ it("resolves credentials from config", async () => {
744
+ mockFetch
745
+ .mockResolvedValueOnce({
746
+ ok: true,
747
+ json: () =>
748
+ Promise.resolve({
749
+ data: [
750
+ {
751
+ guid: "iMessage;-;+15551234567",
752
+ participants: [{ address: "+15551234567" }],
753
+ },
754
+ ],
755
+ }),
756
+ })
757
+ .mockResolvedValueOnce({
758
+ ok: true,
759
+ text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
760
+ });
761
+
762
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
763
+ cfg: {
764
+ channels: {
765
+ bluebubbles: {
766
+ serverUrl: "http://config-server:5678",
767
+ password: "config-pass",
768
+ },
769
+ },
770
+ },
771
+ });
772
+
773
+ expect(result.messageId).toBe("msg-123");
774
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
775
+ expect(calledUrl).toContain("config-server:5678");
776
+ });
777
+
778
+ it("includes tempGuid in request payload", async () => {
779
+ mockFetch
780
+ .mockResolvedValueOnce({
781
+ ok: true,
782
+ json: () =>
783
+ Promise.resolve({
784
+ data: [
785
+ {
786
+ guid: "iMessage;-;+15551234567",
787
+ participants: [{ address: "+15551234567" }],
788
+ },
789
+ ],
790
+ }),
791
+ })
792
+ .mockResolvedValueOnce({
793
+ ok: true,
794
+ text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
795
+ });
796
+
797
+ await sendMessageBlueBubbles("+15551234567", "Hello", {
798
+ serverUrl: "http://localhost:1234",
799
+ password: "test",
800
+ });
801
+
802
+ const sendCall = mockFetch.mock.calls[1];
803
+ const body = JSON.parse(sendCall[1].body);
804
+ expect(body.tempGuid).toBeDefined();
805
+ expect(typeof body.tempGuid).toBe("string");
806
+ expect(body.tempGuid.length).toBeGreaterThan(0);
807
+ });
808
+ });
809
+ });