|
1 | 1 | import { describe, expect, it, vi } from "vitest"; |
2 | | -import { sendBlueBubblesReaction } from "./reactions.js"; |
| 2 | +import { |
| 3 | + normalizeBlueBubblesReactionInput, |
| 4 | + normalizeBlueBubblesReactionInputStrict, |
| 5 | + sendBlueBubblesReaction, |
| 6 | +} from "./reactions.js"; |
3 | 7 | import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; |
4 | 8 |
|
5 | 9 | vi.mock("./accounts.js", async () => { |
@@ -106,18 +110,24 @@ describe("reactions", () => { |
106 | 110 | ).rejects.toThrow("password is required"); |
107 | 111 | }); |
108 | 112 |
|
109 | | - it("throws for unsupported reaction type", async () => { |
110 | | - await expect( |
111 | | - sendBlueBubblesReaction({ |
112 | | - chatGuid: "chat-123", |
113 | | - messageGuid: "msg-123", |
114 | | - emoji: "unsupported", |
115 | | - opts: { |
116 | | - serverUrl: "http://localhost:1234", |
117 | | - password: "test", |
118 | | - }, |
119 | | - }), |
120 | | - ).rejects.toThrow("Unsupported BlueBubbles reaction"); |
| 113 | + it("falls back to love for unsupported reaction type", async () => { |
| 114 | + mockFetch.mockResolvedValueOnce({ |
| 115 | + ok: true, |
| 116 | + text: () => Promise.resolve(""), |
| 117 | + }); |
| 118 | + |
| 119 | + await sendBlueBubblesReaction({ |
| 120 | + chatGuid: "chat-123", |
| 121 | + messageGuid: "msg-123", |
| 122 | + emoji: "👀", |
| 123 | + opts: { |
| 124 | + serverUrl: "http://localhost:1234", |
| 125 | + password: "test", |
| 126 | + }, |
| 127 | + }); |
| 128 | + |
| 129 | + const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| 130 | + expect(body.reaction).toBe("love"); |
121 | 131 | }); |
122 | 132 |
|
123 | 133 | describe("reaction type normalization", () => { |
@@ -236,6 +246,27 @@ describe("reactions", () => { |
236 | 246 | await expectRemovedReaction("-love"); |
237 | 247 | }); |
238 | 248 |
|
| 249 | + it("falls back to removing love for unsupported removal reactions", async () => { |
| 250 | + mockFetch.mockResolvedValueOnce({ |
| 251 | + ok: true, |
| 252 | + text: () => Promise.resolve(""), |
| 253 | + }); |
| 254 | + |
| 255 | + await sendBlueBubblesReaction({ |
| 256 | + chatGuid: "chat-123", |
| 257 | + messageGuid: "msg-123", |
| 258 | + emoji: "👀", |
| 259 | + remove: true, |
| 260 | + opts: { |
| 261 | + serverUrl: "http://localhost:1234", |
| 262 | + password: "test", |
| 263 | + }, |
| 264 | + }); |
| 265 | + |
| 266 | + const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| 267 | + expect(body.reaction).toBe("-love"); |
| 268 | + }); |
| 269 | + |
239 | 270 | it("uses custom partIndex when provided", async () => { |
240 | 271 | mockFetch.mockResolvedValueOnce({ |
241 | 272 | ok: true, |
@@ -335,4 +366,54 @@ describe("reactions", () => { |
335 | 366 | }); |
336 | 367 | }); |
337 | 368 | }); |
| 369 | + |
| 370 | + describe("normalizeBlueBubblesReactionInputStrict", () => { |
| 371 | + it("maps supported emoji to canonical type", () => { |
| 372 | + expect(normalizeBlueBubblesReactionInputStrict("👍")).toBe("like"); |
| 373 | + expect(normalizeBlueBubblesReactionInputStrict("❤️")).toBe("love"); |
| 374 | + expect(normalizeBlueBubblesReactionInputStrict("😂")).toBe("laugh"); |
| 375 | + }); |
| 376 | + |
| 377 | + it("throws on unsupported input so validators can detect misconfiguration", () => { |
| 378 | + expect(() => normalizeBlueBubblesReactionInputStrict("👀")).toThrow( |
| 379 | + /Unsupported BlueBubbles reaction/, |
| 380 | + ); |
| 381 | + expect(() => normalizeBlueBubblesReactionInputStrict("🎉")).toThrow( |
| 382 | + /Unsupported BlueBubbles reaction/, |
| 383 | + ); |
| 384 | + }); |
| 385 | + |
| 386 | + it("throws on empty input", () => { |
| 387 | + expect(() => normalizeBlueBubblesReactionInputStrict("")).toThrow( |
| 388 | + /requires an emoji or name/, |
| 389 | + ); |
| 390 | + expect(() => normalizeBlueBubblesReactionInputStrict(" ")).toThrow( |
| 391 | + /requires an emoji or name/, |
| 392 | + ); |
| 393 | + }); |
| 394 | + }); |
| 395 | + |
| 396 | + describe("normalizeBlueBubblesReactionInput (lenient)", () => { |
| 397 | + it("maps supported emoji to canonical type", () => { |
| 398 | + expect(normalizeBlueBubblesReactionInput("👍")).toBe("like"); |
| 399 | + expect(normalizeBlueBubblesReactionInput("❤️")).toBe("love"); |
| 400 | + }); |
| 401 | + |
| 402 | + it("falls back to love when input is unsupported by iMessage tapback", () => { |
| 403 | + expect(normalizeBlueBubblesReactionInput("👀")).toBe("love"); |
| 404 | + expect(normalizeBlueBubblesReactionInput("🎉")).toBe("love"); |
| 405 | + }); |
| 406 | + |
| 407 | + it("falls back to -love on unsupported remove", () => { |
| 408 | + expect(normalizeBlueBubblesReactionInput("👀", true)).toBe("-love"); |
| 409 | + }); |
| 410 | + |
| 411 | + it("still throws on empty input (strict error bubbles up unchanged)", () => { |
| 412 | + // Empty input is a contract error from the caller, not a decorative |
| 413 | + // emoji the model picked; we intentionally do not mask it. |
| 414 | + expect(() => normalizeBlueBubblesReactionInput("")).toThrow( |
| 415 | + /requires an emoji or name/, |
| 416 | + ); |
| 417 | + }); |
| 418 | + }); |
338 | 419 | }); |
0 commit comments