| import { describe, expect, it, vi } from "vitest"; |
| import "./test-mocks.js"; |
| import { |
| addBlueBubblesParticipant, |
| editBlueBubblesMessage, |
| leaveBlueBubblesChat, |
| markBlueBubblesChatRead, |
| removeBlueBubblesParticipant, |
| renameBlueBubblesChat, |
| sendBlueBubblesTyping, |
| setGroupIconBlueBubbles, |
| unsendBlueBubblesMessage, |
| } from "./chat.js"; |
| import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; |
| import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; |
|
|
| const mockFetch = vi.fn(); |
|
|
| installBlueBubblesFetchTestHooks({ |
| mockFetch, |
| privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), |
| }); |
|
|
| describe("chat", () => { |
| function mockOkTextResponse() { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
| } |
|
|
| function mockTwoOkTextResponses() { |
| mockOkTextResponse(); |
| mockOkTextResponse(); |
| } |
|
|
| async function expectCalledUrlIncludesPassword(params: { |
| password: string; |
| invoke: () => Promise<void>; |
| }) { |
| mockOkTextResponse(); |
| await params.invoke(); |
| const calledUrl = mockFetch.mock.calls[0][0] as string; |
| expect(calledUrl).toContain(`password=${params.password}`); |
| } |
|
|
| async function expectCalledUrlUsesConfigCredentials(params: { |
| serverHost: string; |
| password: string; |
| invoke: (cfg: { |
| channels: { bluebubbles: { serverUrl: string; password: string } }; |
| }) => Promise<void>; |
| }) { |
| mockOkTextResponse(); |
| await params.invoke({ |
| channels: { |
| bluebubbles: { |
| serverUrl: `http://${params.serverHost}`, |
| password: params.password, |
| }, |
| }, |
| }); |
| const calledUrl = mockFetch.mock.calls[0][0] as string; |
| expect(calledUrl).toContain(params.serverHost); |
| expect(calledUrl).toContain(`password=${params.password}`); |
| } |
|
|
| describe("markBlueBubblesChatRead", () => { |
| it("does nothing when chatGuid is empty or whitespace", async () => { |
| for (const chatGuid of ["", " "]) { |
| await markBlueBubblesChatRead(chatGuid, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
| } |
| expect(mockFetch).not.toHaveBeenCalled(); |
| }); |
|
|
| it("throws when required credentials are missing", async () => { |
| await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow( |
| "serverUrl is required", |
| ); |
| 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("does not send read receipt when private API is disabled", async () => { |
| vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); |
|
|
| await markBlueBubblesChatRead("iMessage;-;+15551234567", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
|
|
| expect(mockFetch).not.toHaveBeenCalled(); |
| }); |
|
|
| it("includes password in URL query", async () => { |
| await expectCalledUrlIncludesPassword({ |
| password: "my-secret", |
| invoke: () => |
| markBlueBubblesChatRead("chat-123", { |
| serverUrl: "http://localhost:1234", |
| 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 () => { |
| await expectCalledUrlUsesConfigCredentials({ |
| serverHost: "config-server:9999", |
| password: "config-pass", |
| invoke: (cfg) => |
| markBlueBubblesChatRead("chat-123", { |
| cfg, |
| }), |
| }); |
| }); |
| }); |
|
|
| describe("sendBlueBubblesTyping", () => { |
| it("does nothing when chatGuid is empty or whitespace", async () => { |
| for (const chatGuid of ["", " "]) { |
| await sendBlueBubblesTyping(chatGuid, true, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
| } |
| expect(mockFetch).not.toHaveBeenCalled(); |
| }); |
|
|
| it("throws when required credentials are missing", async () => { |
| await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow( |
| "serverUrl is required", |
| ); |
| await expect( |
| sendBlueBubblesTyping("chat-guid", true, { |
| serverUrl: "http://localhost:1234", |
| }), |
| ).rejects.toThrow("password is required"); |
| }); |
|
|
| it("does not send typing when private API is disabled", async () => { |
| vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); |
|
|
| await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
|
|
| expect(mockFetch).not.toHaveBeenCalled(); |
| }); |
|
|
| it("uses POST for start and DELETE for stop", async () => { |
| mockTwoOkTextResponses(); |
|
|
| await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
| await sendBlueBubblesTyping("iMessage;-;+15551234567", false, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
|
|
| expect(mockFetch).toHaveBeenCalledTimes(2); |
| expect(mockFetch.mock.calls[0][0]).toContain( |
| "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing", |
| ); |
| expect(mockFetch.mock.calls[0][1].method).toBe("POST"); |
| expect(mockFetch.mock.calls[1][0]).toContain( |
| "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing", |
| ); |
| expect(mockFetch.mock.calls[1][1].method).toBe("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"); |
| }); |
| }); |
|
|
| describe("editBlueBubblesMessage", () => { |
| it("throws when required args are missing", async () => { |
| await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid"); |
| await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText"); |
| }); |
|
|
| it("sends edit request with default payload values", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await editBlueBubblesMessage(" message-guid ", " updated text ", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
|
|
| expect(mockFetch).toHaveBeenCalledWith( |
| expect.stringContaining("/api/v1/message/message-guid/edit"), |
| expect.objectContaining({ |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| }), |
| ); |
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body).toEqual({ |
| editedMessage: "updated text", |
| backwardsCompatibilityMessage: "Edited to: updated text", |
| partIndex: 0, |
| }); |
| }); |
|
|
| it("supports custom part index and backwards compatibility message", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await editBlueBubblesMessage("message-guid", "new text", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| partIndex: 3, |
| backwardsCompatMessage: "custom-backwards-message", |
| }); |
|
|
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.partIndex).toBe(3); |
| expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message"); |
| }); |
|
|
| it("throws on non-ok response", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: false, |
| status: 422, |
| text: () => Promise.resolve("Unprocessable"), |
| }); |
|
|
| await expect( |
| editBlueBubblesMessage("message-guid", "new text", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }), |
| ).rejects.toThrow("edit failed (422): Unprocessable"); |
| }); |
| }); |
|
|
| describe("unsendBlueBubblesMessage", () => { |
| it("throws when messageGuid is missing", async () => { |
| await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid"); |
| }); |
|
|
| it("sends unsend request with default part index", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await unsendBlueBubblesMessage(" msg-123 ", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
|
|
| expect(mockFetch).toHaveBeenCalledWith( |
| expect.stringContaining("/api/v1/message/msg-123/unsend"), |
| expect.objectContaining({ |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| }), |
| ); |
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.partIndex).toBe(0); |
| }); |
|
|
| it("uses custom part index", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await unsendBlueBubblesMessage("msg-123", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| partIndex: 2, |
| }); |
|
|
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.partIndex).toBe(2); |
| }); |
| }); |
|
|
| describe("group chat mutation actions", () => { |
| it("renames chat", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await renameBlueBubblesChat(" chat-guid ", "New Group Name", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
|
|
| expect(mockFetch).toHaveBeenCalledWith( |
| expect.stringContaining("/api/v1/chat/chat-guid"), |
| expect.objectContaining({ method: "PUT" }), |
| ); |
| const body = JSON.parse(mockFetch.mock.calls[0][1].body); |
| expect(body.displayName).toBe("New Group Name"); |
| }); |
|
|
| it("adds and removes participant using matching endpoint", async () => { |
| mockTwoOkTextResponses(); |
|
|
| await addBlueBubblesParticipant("chat-guid", "+15551234567", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
| await removeBlueBubblesParticipant("chat-guid", "+15551234567", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
|
|
| expect(mockFetch).toHaveBeenCalledTimes(2); |
| expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant"); |
| expect(mockFetch.mock.calls[0][1].method).toBe("POST"); |
| expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant"); |
| expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); |
|
|
| const addBody = JSON.parse(mockFetch.mock.calls[0][1].body); |
| const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body); |
| expect(addBody.address).toBe("+15551234567"); |
| expect(removeBody.address).toBe("+15551234567"); |
| }); |
|
|
| it("leaves chat without JSON body", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| await leaveBlueBubblesChat("chat-guid", { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
|
|
| expect(mockFetch).toHaveBeenCalledWith( |
| expect.stringContaining("/api/v1/chat/chat-guid/leave"), |
| expect.objectContaining({ method: "POST" }), |
| ); |
| expect(mockFetch.mock.calls[0][1].body).toBeUndefined(); |
| expect(mockFetch.mock.calls[0][1].headers).toBeUndefined(); |
| }); |
| }); |
|
|
| 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 required credentials are missing", async () => { |
| await expect( |
| setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}), |
| ).rejects.toThrow("serverUrl is required"); |
| await expect( |
| setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { |
| serverUrl: "http://localhost:1234", |
| }), |
| ).rejects.toThrow("password is required"); |
| }); |
|
|
| it("throws when private API is disabled", async () => { |
| vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); |
| await expect( |
| setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }), |
| ).rejects.toThrow("requires Private API"); |
| expect(mockFetch).not.toHaveBeenCalled(); |
| }); |
|
|
| it("sets group icon successfully", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(""), |
| }); |
|
|
| const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); |
| 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 () => { |
| await expectCalledUrlIncludesPassword({ |
| password: "my-secret", |
| invoke: () => |
| setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { |
| serverUrl: "http://localhost:1234", |
| 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 () => { |
| await expectCalledUrlUsesConfigCredentials({ |
| serverHost: "config-server:9999", |
| password: "config-pass", |
| invoke: (cfg) => |
| setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { |
| cfg, |
| }), |
| }); |
| }); |
|
|
| 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"); |
| }); |
| }); |
| }); |
|
|