| import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; |
|
|
| import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; |
| import type { BlueBubblesAttachment } from "./types.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("downloadBlueBubblesAttachment", () => { |
| beforeEach(() => { |
| vi.stubGlobal("fetch", mockFetch); |
| mockFetch.mockReset(); |
| }); |
|
|
| afterEach(() => { |
| vi.unstubAllGlobals(); |
| }); |
|
|
| it("throws when guid is missing", async () => { |
| const attachment: BlueBubblesAttachment = {}; |
| await expect( |
| downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }), |
| ).rejects.toThrow("guid is required"); |
| }); |
|
|
| it("throws when guid is empty string", async () => { |
| const attachment: BlueBubblesAttachment = { guid: " " }; |
| await expect( |
| downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }), |
| ).rejects.toThrow("guid is required"); |
| }); |
|
|
| it("throws when serverUrl is missing", async () => { |
| const attachment: BlueBubblesAttachment = { guid: "att-123" }; |
| await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow( |
| "serverUrl is required", |
| ); |
| }); |
|
|
| it("throws when password is missing", async () => { |
| const attachment: BlueBubblesAttachment = { guid: "att-123" }; |
| await expect( |
| downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| }), |
| ).rejects.toThrow("password is required"); |
| }); |
|
|
| it("downloads attachment successfully", async () => { |
| const mockBuffer = new Uint8Array([1, 2, 3, 4]); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers({ "content-type": "image/png" }), |
| arrayBuffer: () => Promise.resolve(mockBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { guid: "att-123" }; |
| const result = await downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test-password", |
| }); |
|
|
| expect(result.buffer).toEqual(mockBuffer); |
| expect(result.contentType).toBe("image/png"); |
| expect(mockFetch).toHaveBeenCalledWith( |
| expect.stringContaining("/api/v1/attachment/att-123/download"), |
| expect.objectContaining({ method: "GET" }), |
| ); |
| }); |
|
|
| it("includes password in URL query", async () => { |
| const mockBuffer = new Uint8Array([1, 2, 3, 4]); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers({ "content-type": "image/jpeg" }), |
| arrayBuffer: () => Promise.resolve(mockBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { guid: "att-456" }; |
| await downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "my-secret-password", |
| }); |
|
|
| const calledUrl = mockFetch.mock.calls[0][0] as string; |
| expect(calledUrl).toContain("password=my-secret-password"); |
| }); |
|
|
| it("encodes guid in URL", async () => { |
| const mockBuffer = new Uint8Array([1]); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers(), |
| arrayBuffer: () => Promise.resolve(mockBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" }; |
| await downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
|
|
| const calledUrl = mockFetch.mock.calls[0][0] as string; |
| expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars"); |
| }); |
|
|
| it("throws on non-ok response", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: false, |
| status: 404, |
| text: () => Promise.resolve("Attachment not found"), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { guid: "att-missing" }; |
| await expect( |
| downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }), |
| ).rejects.toThrow("download failed (404): Attachment not found"); |
| }); |
|
|
| it("throws when attachment exceeds max bytes", async () => { |
| const largeBuffer = new Uint8Array(10 * 1024 * 1024); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers(), |
| arrayBuffer: () => Promise.resolve(largeBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { guid: "att-large" }; |
| await expect( |
| downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| maxBytes: 5 * 1024 * 1024, |
| }), |
| ).rejects.toThrow("too large"); |
| }); |
|
|
| it("uses default max bytes when not specified", async () => { |
| const largeBuffer = new Uint8Array(9 * 1024 * 1024); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers(), |
| arrayBuffer: () => Promise.resolve(largeBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { guid: "att-large" }; |
| await expect( |
| downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }), |
| ).rejects.toThrow("too large"); |
| }); |
|
|
| it("uses attachment mimeType as fallback when response has no content-type", async () => { |
| const mockBuffer = new Uint8Array([1, 2, 3]); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers(), |
| arrayBuffer: () => Promise.resolve(mockBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { |
| guid: "att-789", |
| mimeType: "video/mp4", |
| }; |
| const result = await downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
|
|
| expect(result.contentType).toBe("video/mp4"); |
| }); |
|
|
| it("prefers response content-type over attachment mimeType", async () => { |
| const mockBuffer = new Uint8Array([1, 2, 3]); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers({ "content-type": "image/webp" }), |
| arrayBuffer: () => Promise.resolve(mockBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { |
| guid: "att-xyz", |
| mimeType: "image/png", |
| }; |
| const result = await downloadBlueBubblesAttachment(attachment, { |
| serverUrl: "http://localhost:1234", |
| password: "test", |
| }); |
|
|
| expect(result.contentType).toBe("image/webp"); |
| }); |
|
|
| it("resolves credentials from config when opts not provided", async () => { |
| const mockBuffer = new Uint8Array([1]); |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| headers: new Headers(), |
| arrayBuffer: () => Promise.resolve(mockBuffer.buffer), |
| }); |
|
|
| const attachment: BlueBubblesAttachment = { guid: "att-config" }; |
| const result = await downloadBlueBubblesAttachment(attachment, { |
| cfg: { |
| channels: { |
| bluebubbles: { |
| serverUrl: "http://config-server:5678", |
| password: "config-password", |
| }, |
| }, |
| }, |
| }); |
|
|
| const calledUrl = mockFetch.mock.calls[0][0] as string; |
| expect(calledUrl).toContain("config-server:5678"); |
| expect(calledUrl).toContain("password=config-password"); |
| expect(result.buffer).toEqual(new Uint8Array([1])); |
| }); |
| }); |
|
|
| describe("sendBlueBubblesAttachment", () => { |
| beforeEach(() => { |
| vi.stubGlobal("fetch", mockFetch); |
| mockFetch.mockReset(); |
| }); |
|
|
| afterEach(() => { |
| vi.unstubAllGlobals(); |
| }); |
|
|
| function decodeBody(body: Uint8Array) { |
| return Buffer.from(body).toString("utf8"); |
| } |
|
|
| it("marks voice memos when asVoice is true and mp3 is provided", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })), |
| }); |
|
|
| await sendBlueBubblesAttachment({ |
| to: "chat_guid:iMessage;-;+15551234567", |
| buffer: new Uint8Array([1, 2, 3]), |
| filename: "voice.mp3", |
| contentType: "audio/mpeg", |
| asVoice: true, |
| opts: { serverUrl: "http://localhost:1234", password: "test" }, |
| }); |
|
|
| const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; |
| const bodyText = decodeBody(body); |
| expect(bodyText).toContain('name="isAudioMessage"'); |
| expect(bodyText).toContain("true"); |
| expect(bodyText).toContain('filename="voice.mp3"'); |
| }); |
|
|
| it("normalizes mp3 filenames for voice memos", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })), |
| }); |
|
|
| await sendBlueBubblesAttachment({ |
| to: "chat_guid:iMessage;-;+15551234567", |
| buffer: new Uint8Array([1, 2, 3]), |
| filename: "voice", |
| contentType: "audio/mpeg", |
| asVoice: true, |
| opts: { serverUrl: "http://localhost:1234", password: "test" }, |
| }); |
|
|
| const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; |
| const bodyText = decodeBody(body); |
| expect(bodyText).toContain('filename="voice.mp3"'); |
| expect(bodyText).toContain('name="voice.mp3"'); |
| }); |
|
|
| it("throws when asVoice is true but media is not audio", async () => { |
| await expect( |
| sendBlueBubblesAttachment({ |
| to: "chat_guid:iMessage;-;+15551234567", |
| buffer: new Uint8Array([1, 2, 3]), |
| filename: "image.png", |
| contentType: "image/png", |
| asVoice: true, |
| opts: { serverUrl: "http://localhost:1234", password: "test" }, |
| }), |
| ).rejects.toThrow("voice messages require audio"); |
| expect(mockFetch).not.toHaveBeenCalled(); |
| }); |
|
|
| it("throws when asVoice is true but audio is not mp3 or caf", async () => { |
| await expect( |
| sendBlueBubblesAttachment({ |
| to: "chat_guid:iMessage;-;+15551234567", |
| buffer: new Uint8Array([1, 2, 3]), |
| filename: "voice.wav", |
| contentType: "audio/wav", |
| asVoice: true, |
| opts: { serverUrl: "http://localhost:1234", password: "test" }, |
| }), |
| ).rejects.toThrow("require mp3 or caf"); |
| expect(mockFetch).not.toHaveBeenCalled(); |
| }); |
|
|
| it("sanitizes filenames before sending", async () => { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })), |
| }); |
|
|
| await sendBlueBubblesAttachment({ |
| to: "chat_guid:iMessage;-;+15551234567", |
| buffer: new Uint8Array([1, 2, 3]), |
| filename: "../evil.mp3", |
| contentType: "audio/mpeg", |
| opts: { serverUrl: "http://localhost:1234", password: "test" }, |
| }); |
|
|
| const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; |
| const bodyText = decodeBody(body); |
| expect(bodyText).toContain('filename="evil.mp3"'); |
| expect(bodyText).toContain('name="evil.mp3"'); |
| }); |
| }); |
|
|