394 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|
|
});
|