Files
clawdbot/extensions/bluebubbles/src/reactions.test.ts
2026-01-20 12:07:54 +00:00

394 lines
11 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { sendBlueBubblesReaction } from "./reactions.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
const mockFetch = vi.fn();
describe("reactions", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("sendBlueBubblesReaction", () => {
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 () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
});
it("strips leading dash from emoji when remove flag is set", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "-love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-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 () => {
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("-like");
});
it("handles text alias removal", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "haha",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-laugh");
});
});
});
});