| import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; |
| import { sendBlueBubblesReaction } from "./reactions.js"; |
|
|
| vi.mock("./accounts.js", async () => { |
| const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); |
| return createBlueBubblesAccountsMockModule(); |
| }); |
|
|
| const mockFetch = vi.fn(); |
|
|
| describe("reactions", () => { |
| beforeEach(() => { |
| vi.stubGlobal("fetch", mockFetch); |
| mockFetch.mockReset(); |
| }); |
|
|
| afterEach(() => { |
| vi.unstubAllGlobals(); |
| }); |
|
|
| describe("sendBlueBubblesReaction", () => { |
| async function expectRemovedReaction(emoji: string, expectedReaction = "-love") { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji, |
| remove: true, |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }); |
|
|
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.reaction).toBe(expectedReaction); |
| } |
|
|
| it("throws when chatGuid is empty", async () => { |
| await expect( |
| sendBlueBubblesReaction({ |
| chatGuid: "", |
| messageGuid: "msg-123", |
| emoji: "love", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }), |
| ).rejects.toThrow("chatGuid"); |
| }); |
|
|
| it("throws when messageGuid is empty", async () => { |
| await expect( |
| sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "", |
| emoji: "love", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }), |
| ).rejects.toThrow("messageGuid"); |
| }); |
|
|
| it("throws when emoji is empty", async () => { |
| await expect( |
| sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }), |
| ).rejects.toThrow("emoji or name"); |
| }); |
|
|
| it("throws when serverUrl is missing", async () => { |
| await expect( |
| sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "love", |
| opts: {}, |
| }), |
| ).rejects.toThrow("serverUrl is required"); |
| }); |
|
|
| it("throws when password is missing", async () => { |
| await expect( |
| sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "love", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| }, |
| }), |
| ).rejects.toThrow("password is required"); |
| }); |
|
|
| it("throws for unsupported reaction type", async () => { |
| await expect( |
| sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "unsupported", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }), |
| ).rejects.toThrow("Unsupported BlueBubbles reaction"); |
| }); |
|
|
| describe("reaction type normalization", () => { |
| const testCases = [ |
| { input: "love", expected: "love" }, |
| { input: "like", expected: "like" }, |
| { input: "dislike", expected: "dislike" }, |
| { input: "laugh", expected: "laugh" }, |
| { input: "emphasize", expected: "emphasize" }, |
| { input: "question", expected: "question" }, |
| { input: "heart", expected: "love" }, |
| { input: "thumbs_up", expected: "like" }, |
| { input: "thumbs-down", expected: "dislike" }, |
| { input: "thumbs_down", expected: "dislike" }, |
| { input: "haha", expected: "laugh" }, |
| { input: "lol", expected: "laugh" }, |
| { input: "emphasis", expected: "emphasize" }, |
| { input: "exclaim", expected: "emphasize" }, |
| { input: "❤️", expected: "love" }, |
| { input: "❤", expected: "love" }, |
| { input: "♥️", expected: "love" }, |
| { input: "😍", expected: "love" }, |
| { input: "👍", expected: "like" }, |
| { input: "👎", expected: "dislike" }, |
| { input: "😂", expected: "laugh" }, |
| { input: "🤣", expected: "laugh" }, |
| { input: "😆", expected: "laugh" }, |
| { input: "‼️", expected: "emphasize" }, |
| { input: "‼", expected: "emphasize" }, |
| { input: "❗", expected: "emphasize" }, |
| { input: "❓", expected: "question" }, |
| { input: "❔", expected: "question" }, |
| { input: "LOVE", expected: "love" }, |
| { input: "Like", expected: "like" }, |
| ]; |
|
|
| for (const { input, expected } of testCases) { |
| it(`normalizes "${input}" to "${expected}"`, async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: input, |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }); |
|
|
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.reaction).toBe(expected); |
| }); |
| } |
| }); |
|
|
| it("sends reaction successfully", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await sendBlueBubblesReaction({ |
| chatGuid: "iMessage;-;+15551234567", |
| messageGuid: "msg-uuid-123", |
| emoji: "love", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }, |
| }); |
|
|
| expect(mockFetch).toHaveBeenCalledWith( |
| expect.stringContaining("/api/v1/message/react"), |
| expect.objectContaining({ |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| }), |
| ); |
|
|
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.chatGuid).toBe("iMessage;-;+15551234567"); |
| expect(body.selectedMessageGuid).toBe("msg-uuid-123"); |
| expect(body.reaction).toBe("love"); |
| expect(body.partIndex).toBe(0); |
| }); |
|
|
| it("includes password in URL query", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "like", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "my-react-password", |
| }, |
| }); |
|
|
| const calledUrl = mockFetch.mock.calls[0][0] as string; |
| expect(calledUrl).toContain("password=my-react-password"); |
| }); |
|
|
| it("sends reaction removal with dash prefix", async () => { |
| await expectRemovedReaction("love"); |
| }); |
|
|
| it("strips leading dash from emoji when remove flag is set", async () => { |
| await expectRemovedReaction("-love"); |
| }); |
|
|
| it("uses custom partIndex when provided", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "laugh", |
| partIndex: 3, |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }); |
|
|
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.partIndex).toBe(3); |
| }); |
|
|
| it("throws on non-ok response", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: false, |
| status: 400, |
| text: () => Promise.resolve("Invalid reaction type"), |
| }); |
|
|
| await expect( |
| sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "like", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }), |
| ).rejects.toThrow("reaction failed (400): Invalid reaction type"); |
| }); |
|
|
| it("resolves credentials from config", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await sendBlueBubblesReaction({ |
| chatGuid: "chat-123", |
| messageGuid: "msg-123", |
| emoji: "emphasize", |
| opts: { |
| cfg: { |
| channels: { |
| bluebubbles: { |
| serverUrl: "http://react-server:7777", |
| password: "react-pass", |
| }, |
| }, |
| }, |
| }, |
| }); |
|
|
| const calledUrl = mockFetch.mock.calls[0][0] as string; |
| expect(calledUrl).toContain("react-server:7777"); |
| expect(calledUrl).toContain("password=react-pass"); |
| }); |
|
|
| it("trims chatGuid and messageGuid", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await sendBlueBubblesReaction({ |
| chatGuid: " chat-with-spaces ", |
| messageGuid: " msg-with-spaces ", |
| emoji: "question", |
| opts: { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }, |
| }); |
|
|
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.chatGuid).toBe("chat-with-spaces"); |
| expect(body.selectedMessageGuid).toBe("msg-with-spaces"); |
| }); |
|
|
| describe("reaction removal aliases", () => { |
| it("handles emoji-based removal", async () => { |
| await expectRemovedReaction("👍", "-like"); |
| }); |
|
|
| it("handles text alias removal", async () => { |
| await expectRemovedReaction("haha", "-laugh"); |
| }); |
| }); |
| }); |
| }); |
|
|