(Step 2) Phase 2 & 3 Complete + Reviewed
This commit is contained in:
committed by
Peter Steinberger
parent
ac2fcfe96a
commit
e9d691d472
240
extensions/bluebubbles/src/attachments.test.ts
Normal file
240
extensions/bluebubbles/src/attachments.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { downloadBlueBubblesAttachment } 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]));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user