(Step 2) Phase 2 & 3 Complete + Reviewed
This commit is contained in:
committed by
Peter Steinberger
parent
ac2fcfe96a
commit
e9d691d472
393
extensions/bluebubbles/src/reactions.test.ts
Normal file
393
extensions/bluebubbles/src/reactions.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user