Files
clawdbot/extensions/bluebubbles/src/attachments.test.ts
2026-01-23 04:38:08 +00:00

347 lines
11 KiB
TypeScript

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"');
});
});