import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.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("chat", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("markBlueBubblesChatRead", () => { it("does nothing when chatGuid is empty", async () => { await markBlueBubblesChatRead("", { serverUrl: "http://localhost:1234", password: "test", }); expect(mockFetch).not.toHaveBeenCalled(); }); it("does nothing when chatGuid is whitespace", async () => { await markBlueBubblesChatRead(" ", { serverUrl: "http://localhost:1234", password: "test", }); expect(mockFetch).not.toHaveBeenCalled(); }); it("throws when serverUrl is missing", async () => { await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow( "serverUrl is required", ); }); it("throws when password is missing", async () => { await expect( markBlueBubblesChatRead("chat-guid", { serverUrl: "http://localhost:1234", }), ).rejects.toThrow("password is required"); }); it("marks chat as read successfully", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await markBlueBubblesChatRead("iMessage;-;+15551234567", { serverUrl: "http://localhost:1234", password: "test-password", }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"), expect.objectContaining({ method: "POST" }), ); }); it("includes password in URL query", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await markBlueBubblesChatRead("chat-123", { serverUrl: "http://localhost:1234", password: "my-secret", }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("Chat not found"), }); await expect( markBlueBubblesChatRead("missing-chat", { serverUrl: "http://localhost:1234", password: "test", }), ).rejects.toThrow("read failed (404): Chat not found"); }); it("trims chatGuid before using", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await markBlueBubblesChatRead(" chat-with-spaces ", { serverUrl: "http://localhost:1234", password: "test", }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read"); expect(calledUrl).not.toContain("%20chat"); }); it("resolves credentials from config", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await markBlueBubblesChatRead("chat-123", { cfg: { channels: { bluebubbles: { serverUrl: "http://config-server:9999", password: "config-pass", }, }, }, }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("config-server:9999"); expect(calledUrl).toContain("password=config-pass"); }); }); describe("sendBlueBubblesTyping", () => { it("does nothing when chatGuid is empty", async () => { await sendBlueBubblesTyping("", true, { serverUrl: "http://localhost:1234", password: "test", }); expect(mockFetch).not.toHaveBeenCalled(); }); it("does nothing when chatGuid is whitespace", async () => { await sendBlueBubblesTyping(" ", false, { serverUrl: "http://localhost:1234", password: "test", }); expect(mockFetch).not.toHaveBeenCalled(); }); it("throws when serverUrl is missing", async () => { await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow( "serverUrl is required", ); }); it("throws when password is missing", async () => { await expect( sendBlueBubblesTyping("chat-guid", true, { serverUrl: "http://localhost:1234", }), ).rejects.toThrow("password is required"); }); it("sends typing start with POST method", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { serverUrl: "http://localhost:1234", password: "test", }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"), expect.objectContaining({ method: "POST" }), ); }); it("sends typing stop with DELETE method", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await sendBlueBubblesTyping("iMessage;-;+15551234567", false, { serverUrl: "http://localhost:1234", password: "test", }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"), expect.objectContaining({ method: "DELETE" }), ); }); it("includes password in URL query", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await sendBlueBubblesTyping("chat-123", true, { serverUrl: "http://localhost:1234", password: "typing-secret", }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("password=typing-secret"); }); it("throws on non-ok response", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("Internal error"), }); await expect( sendBlueBubblesTyping("chat-123", true, { serverUrl: "http://localhost:1234", password: "test", }), ).rejects.toThrow("typing failed (500): Internal error"); }); it("trims chatGuid before using", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await sendBlueBubblesTyping(" trimmed-chat ", true, { serverUrl: "http://localhost:1234", password: "test", }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing"); }); it("encodes special characters in chatGuid", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, { serverUrl: "http://localhost:1234", password: "test", }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com"); }); it("resolves credentials from config", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await sendBlueBubblesTyping("chat-123", true, { cfg: { channels: { bluebubbles: { serverUrl: "http://typing-server:8888", password: "typing-pass", }, }, }, }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("typing-server:8888"); expect(calledUrl).toContain("password=typing-pass"); }); it("can start and stop typing in sequence", async () => { mockFetch .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }) .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await sendBlueBubblesTyping("chat-123", true, { serverUrl: "http://localhost:1234", password: "test", }); await sendBlueBubblesTyping("chat-123", false, { serverUrl: "http://localhost:1234", password: "test", }); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch.mock.calls[0][1].method).toBe("POST"); expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); }); }); describe("setGroupIconBlueBubbles", () => { it("throws when chatGuid is empty", async () => { await expect( setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", { serverUrl: "http://localhost:1234", password: "test", }), ).rejects.toThrow("chatGuid"); }); it("throws when buffer is empty", async () => { await expect( setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", { serverUrl: "http://localhost:1234", password: "test", }), ).rejects.toThrow("image buffer"); }); it("throws when serverUrl is missing", async () => { await expect( setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}), ).rejects.toThrow("serverUrl is required"); }); it("throws when password is missing", async () => { await expect( setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { serverUrl: "http://localhost:1234", }), ).rejects.toThrow("password is required"); }); it("sets group icon successfully", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", { serverUrl: "http://localhost:1234", password: "test-password", contentType: "image/png", }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"), expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "Content-Type": expect.stringContaining("multipart/form-data"), }), }), ); }); it("includes password in URL query", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { serverUrl: "http://localhost:1234", password: "my-secret", }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("Internal error"), }); await expect( setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { serverUrl: "http://localhost:1234", password: "test", }), ).rejects.toThrow("setGroupIcon failed (500): Internal error"); }); it("trims chatGuid before using", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", { serverUrl: "http://localhost:1234", password: "test", }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon"); expect(calledUrl).not.toContain("%20chat"); }); it("resolves credentials from config", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { cfg: { channels: { bluebubbles: { serverUrl: "http://config-server:9999", password: "config-pass", }, }, }, }); const calledUrl = mockFetch.mock.calls[0][0] as string; expect(calledUrl).toContain("config-server:9999"); expect(calledUrl).toContain("password=config-pass"); }); it("includes filename in multipart body", async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), }); await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", { serverUrl: "http://localhost:1234", password: "test", contentType: "image/jpeg", }); const body = mockFetch.mock.calls[0][1].body as Uint8Array; const bodyString = new TextDecoder().decode(body); expect(bodyString).toContain('filename="custom-icon.jpg"'); expect(bodyString).toContain("image/jpeg"); }); }); });