Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  attachments.test.ts

  Sprache: JAVA
 

Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import {
  downloadBlueBubblesAttachment,
  fetchBlueBubblesMessageAttachments,
  sendBlueBubblesAttachment,
} from "./attachments.js";
import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import {
  BLUE_BUBBLES_PRIVATE_API_STATUS,
  installBlueBubblesFetchTestHooks,
  mockBlueBubblesPrivateApiStatus,
  mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import {
  createBlueBubblesFetchRemoteMediaMock,
  createBlueBubblesRuntimeStub,
} from "./test-helpers.js";
import type { BlueBubblesAttachment } from "./types.js";

const mockFetch = vi.fn();
const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo);
const fetchRemoteMediaMock = createBlueBubblesFetchRemoteMediaMock({
  createHttpError: async ({ response, url }) => {
    const text = await response.text().catch(() => "unknown");
    return new Error(`Failed to fetch media from ${url}: HTTP ${response.status}; body: ${text}`);
  },
});

installBlueBubblesFetchTestHooks({
  mockFetch,
  privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});

const runtimeStub = createBlueBubblesRuntimeStub(fetchRemoteMediaMock);

describe("downloadBlueBubblesAttachment", () => {
  beforeEach(() => {
    fetchRemoteMediaMock.mockClear();
    mockFetch.mockReset();
    setBlueBubblesRuntime(runtimeStub);
  });

  async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
    const largeBuffer = new Uint8Array(params.bufferBytes);
    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",
        ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
      }),
    ).rejects.toThrow("too large");
  }

  function mockSuccessfulAttachmentDownload(buffer = new Uint8Array([1])) {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      headers: new Headers(),
      arrayBuffer: () => Promise.resolve(buffer.buffer),
    });
    return buffer;
  }

  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 () => {
    mockSuccessfulAttachmentDownload();

    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("Attachment not found");
  });

  it("throws when attachment exceeds max bytes", async () => {
    await expectAttachmentTooLarge({
      bufferBytes: 10 * 1024 * 1024,
      maxBytes: 5 * 1024 * 1024,
    });
  });

  it("uses default max bytes when not specified", async () => {
    await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
  });

  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 () => {
    mockSuccessfulAttachmentDownload();

    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]));
  });

  it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
    mockSuccessfulAttachmentDownload();

    const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
    await downloadBlueBubblesAttachment(attachment, {
      cfg: {
        channels: {
          bluebubbles: {
            serverUrl: "http://localhost:1234",
            password: "test",
            network: {
              dangerouslyAllowPrivateNetwork: true,
            },
          },
        },
      },
    });

    const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
    expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
  });

  it("auto-enables private-network fetches for loopback serverUrl when allowPrivateNetwork is not set", async () => {
    mockSuccessfulAttachmentDownload();

    const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
    await downloadBlueBubblesAttachment(attachment, {
      serverUrl: "http://localhost:1234",
      password: "test",
      cfg: { channels: { bluebubbles: {} } },
    });

    const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
    expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
  });

  it("auto-enables private-network fetches for private IP serverUrl when allowPrivateNetwork is not set", async () => {
    mockSuccessfulAttachmentDownload();

    const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
    await downloadBlueBubblesAttachment(attachment, {
      serverUrl: "http://192.168.1.5:1234",
      password: "test",
      cfg: { channels: { bluebubbles: {} } },
    });

    const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
    expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
  });

  it("respects an explicit private-network opt-out for loopback serverUrl", async () => {
    mockSuccessfulAttachmentDownload();

    const attachment: BlueBubblesAttachment = { guid: "att-opt-out" };
    await downloadBlueBubblesAttachment(attachment, {
      serverUrl: "http://localhost:1234",
      password: "test",
      cfg: {
        channels: {
          bluebubbles: {
            network: {
              dangerouslyAllowPrivateNetwork: false,
            },
          },
        },
      },
    });

    // Default-deny policy via the guard, NOT unguarded fetch. Aisle #68234
    // flagged the previous `undefined` fallback as a real SSRF bypass because
    // `blueBubblesFetchWithTimeout` treats `undefined` as "skip the SSRF
    // guard entirely", exactly when the user asked us to block private nets.
    const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
    expect(fetchMediaArgs.ssrfPolicy).toEqual({});
  });

  it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => {
    mockSuccessfulAttachmentDownload();

    const attachment: BlueBubblesAttachment = { guid: "att-public-host" };
    await downloadBlueBubblesAttachment(attachment, {
      serverUrl: "https://bluebubbles.example.com:1234",
      password: "test",
    });

    const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
    expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
  });

  it("keeps public serverUrl hostname pinning when private-network access is explicitly disabled", async () => {
    mockSuccessfulAttachmentDownload();

    const attachment: BlueBubblesAttachment = { guid: "att-public-host-opt-out" };
    await downloadBlueBubblesAttachment(attachment, {
      serverUrl: "https://bluebubbles.example.com:1234",
      password: "test",
      cfg: {
        channels: {
          bluebubbles: {
            network: {
              dangerouslyAllowPrivateNetwork: false,
            },
          },
        },
      },
    });

    const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
    expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
  });
});

describe("sendBlueBubblesAttachment", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    mockFetch.mockReset();
    fetchRemoteMediaMock.mockClear();
    fetchServerInfoMock.mockReset();
    fetchServerInfoMock.mockResolvedValue(null);
    setBlueBubblesRuntime(runtimeStub);
    vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
    mockBlueBubblesPrivateApiStatus(
      vi.mocked(getCachedBlueBubblesPrivateApiStatus),
      BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
    );
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  function decodeBody(body: Uint8Array) {
    return Buffer.from(body).toString("utf8");
  }

  function expectVoiceAttachmentBody() {
    const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
    const bodyText = decodeBody(body);
    expect(bodyText).toContain('name="isAudioMessage"');
    expect(bodyText).toContain("true");
    return bodyText;
  }

  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 bodyText = expectVoiceAttachmentBody();
    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 bodyText = expectVoiceAttachmentBody();
    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"');
  });

  it("downgrades attachment reply threading when private API is disabled", async () => {
    mockBlueBubblesPrivateApiStatusOnce(
      vi.mocked(getCachedBlueBubblesPrivateApiStatus),
      BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
    );
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
    });

    await sendBlueBubblesAttachment({
      to: "chat_guid:iMessage;-;+15551234567",
      buffer: new Uint8Array([1, 2, 3]),
      filename: "photo.jpg",
      contentType: "image/jpeg",
      replyToMessageGuid: "reply-guid-123",
      opts: { serverUrl: "http://localhost:1234", password: "test" },
    });

    const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
    const bodyText = decodeBody(body);
    expect(bodyText).not.toContain('name="method"');
    expect(bodyText).not.toContain('name="selectedMessageGuid"');
    expect(bodyText).not.toContain('name="partIndex"');
  });

  it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
    const runtimeLog = vi.fn();
    setBlueBubblesRuntime({
      ...runtimeStub,
      log: runtimeLog,
    } as unknown as PluginRuntime);
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
    });

    await sendBlueBubblesAttachment({
      to: "chat_guid:iMessage;-;+15551234567",
      buffer: new Uint8Array([1, 2, 3]),
      filename: "photo.jpg",
      contentType: "image/jpeg",
      replyToMessageGuid: "reply-guid-unknown",
      opts: { serverUrl: "http://localhost:1234", password: "test" },
    });

    expect(runtimeLog).toHaveBeenCalledTimes(1);
    expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
    const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
    const bodyText = decodeBody(body);
    expect(bodyText).not.toContain('name="selectedMessageGuid"');
    expect(bodyText).not.toContain('name="partIndex"');
  });

  it("auto-creates a new chat when sending to a phone number with no existing chat", async () => {
    // First call: resolveChatGuidForTarget queries chats, returns empty (no match)
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ data: [] }),
    });
    // Second call: createChatForHandle creates new chat
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: () =>
        Promise.resolve(
          JSON.stringify({
            data: { chatGuid: "iMessage;-;+15559876543", guid: "iMessage;-;+15559876543" },
          }),
        ),
    });
    // Third call: actual attachment send
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-1" } })),
    });

    const result = await sendBlueBubblesAttachment({
      to: "+15559876543",
      buffer: new Uint8Array([1, 2, 3]),
      filename: "photo.jpg",
      contentType: "image/jpeg",
      opts: { serverUrl: "http://localhost:1234", password: "test" },
    });

    expect(result.messageId).toBe("attach-msg-1");
    // Verify chat creation was called
    const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body);
    expect(createCallBody.addresses).toEqual(["+15559876543"]);
    // Verify attachment was sent to the newly created chat
    const attachBody = mockFetch.mock.calls[2][1]?.body as Uint8Array;
    const attachText = decodeBody(attachBody);
    expect(attachText).toContain("iMessage;-;+15559876543");
  });

  it("retries chatGuid resolution after creating a chat with no returned guid", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ data: [] }),
    });
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: () => Promise.resolve(JSON.stringify({ data: {} })),
    });
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ data: [{ guid: "iMessage;-;+15557654321" }] }),
    });
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-2" } })),
    });

    const result = await sendBlueBubblesAttachment({
      to: "+15557654321",
      buffer: new Uint8Array([4, 5, 6]),
      filename: "photo.jpg",
      contentType: "image/jpeg",
      opts: { serverUrl: "http://localhost:1234", password: "test" },
    });

    expect(result.messageId).toBe("attach-msg-2");
    const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body);
    expect(createCallBody.addresses).toEqual(["+15557654321"]);
    const attachBody = mockFetch.mock.calls[3][1]?.body as Uint8Array;
    const attachText = decodeBody(attachBody);
    expect(attachText).toContain("iMessage;-;+15557654321");
  });

  describe("lazy private API refresh (#43764)", () => {
    const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);

    it("refreshes cache when expired and reply threading is requested", async () => {
      privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true);
      fetchServerInfoMock.mockResolvedValueOnce({ private_api: true });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-refreshed" } })),
      });

      const result = await sendBlueBubblesAttachment({
        to: "chat_guid:iMessage;-;+15551234567",
        buffer: new Uint8Array([1, 2, 3]),
        filename: "photo.jpg",
        contentType: "image/jpeg",
        replyToMessageGuid: "reply-guid-456",
        opts: { serverUrl: "http://localhost:1234", password: "test" },
      });

      expect(result.messageId).toBe("msg-refreshed");
      expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
      const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
      const bodyText = decodeBody(body);
      expect(bodyText).toContain('name="method"');
      expect(bodyText).toContain("private-api");
      expect(bodyText).toContain('name="selectedMessageGuid"');
    });

    it("does not refresh when cache is populated (cache hit)", async () => {
      mockBlueBubblesPrivateApiStatusOnce(
        privateApiStatusMock,
        BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
      );
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-cached" } })),
      });

      await sendBlueBubblesAttachment({
        to: "chat_guid:iMessage;-;+15551234567",
        buffer: new Uint8Array([1, 2, 3]),
        filename: "photo.jpg",
        contentType: "image/jpeg",
        replyToMessageGuid: "reply-guid-123",
        opts: { serverUrl: "http://localhost:1234", password: "test" },
      });

      expect(fetchServerInfoMock).not.toHaveBeenCalled();
    });

    it("degrades gracefully when refresh fails", async () => {
      fetchServerInfoMock.mockRejectedValueOnce(new Error("network error"));
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-degraded" } })),
      });

      const runtimeLog = vi.fn();
      setBlueBubblesRuntime({
        ...runtimeStub,
        log: runtimeLog,
      } as unknown as PluginRuntime);

      const result = await sendBlueBubblesAttachment({
        to: "chat_guid:iMessage;-;+15551234567",
        buffer: new Uint8Array([1, 2, 3]),
        filename: "photo.jpg",
        contentType: "image/jpeg",
        replyToMessageGuid: "reply-guid-789",
        opts: { serverUrl: "http://localhost:1234", password: "test" },
      });

      expect(result.messageId).toBe("msg-degraded");
      expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
      expect(runtimeLog).toHaveBeenCalledTimes(1);
      expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
    });

    it("degrades reply threading when refresh succeeds with private_api: false", async () => {
      privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false);
      fetchServerInfoMock.mockResolvedValueOnce({ private_api: false });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-disabled" } })),
      });

      const runtimeLog = vi.fn();
      setBlueBubblesRuntime({
        ...runtimeStub,
        log: runtimeLog,
      } as unknown as PluginRuntime);

      const result = await sendBlueBubblesAttachment({
        to: "chat_guid:iMessage;-;+15551234567",
        buffer: new Uint8Array([1, 2, 3]),
        filename: "photo.jpg",
        contentType: "image/jpeg",
        replyToMessageGuid: "reply-guid-disabled",
        opts: { serverUrl: "http://localhost:1234", password: "test" },
      });

      expect(result.messageId).toBe("msg-disabled");
      expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
      // No warning — status is known (disabled), not unknown
      expect(runtimeLog).not.toHaveBeenCalled();
      const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
      const bodyText = decodeBody(body);
      expect(bodyText).not.toContain('name="selectedMessageGuid"');
      expect(bodyText).not.toContain('name="method"');
    });

    it("does not refresh when no reply threading is requested", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-plain" } })),
      });

      await sendBlueBubblesAttachment({
        to: "chat_guid:iMessage;-;+15551234567",
        buffer: new Uint8Array([1, 2, 3]),
        filename: "photo.jpg",
        contentType: "image/jpeg",
        opts: { serverUrl: "http://localhost:1234", password: "test" },
      });

      expect(fetchServerInfoMock).not.toHaveBeenCalled();
    });
  });

  it("still throws for non-handle targets when chatGuid is not found", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ data: [] }),
    });

    await expect(
      sendBlueBubblesAttachment({
        to: "chat_id:999",
        buffer: new Uint8Array([1, 2, 3]),
        filename: "photo.jpg",
        opts: { serverUrl: "http://localhost:1234", password: "test" },
      }),
    ).rejects.toThrow("chatGuid not found");
  });
});

describe("fetchBlueBubblesMessageAttachments", () => {
  beforeEach(() => {
    mockFetch.mockReset();
  });

  it("returns attachments from the BB API response", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () =>
        Promise.resolve({
          data: {
            attachments: [
              {
                guid: "att-1",
                mimeType: "image/jpeg",
                transferName: "photo.jpg",
                totalBytes: 1024,
              },
              {
                guid: "att-2",
                mime_type: "image/png",
                transfer_name: "screenshot.png",
                total_bytes: 2048,
              },
            ],
          },
        }),
    });
    const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
      baseUrl: "http://localhost:1234",
      password: "test",
    });
    expect(result).toHaveLength(2);
    expect(result[0].guid).toBe("att-1");
    expect(result[0].mimeType).toBe("image/jpeg");
    expect(result[1].guid).toBe("att-2");
    expect(result[1].mimeType).toBe("image/png");
  });

  it("returns empty array on non-ok HTTP response", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: false,
      status: 404,
    });
    const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
      baseUrl: "http://localhost:1234",
      password: "test",
    });
    expect(result).toEqual([]);
  });

  it("returns empty array when data has no attachments", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ data: {} }),
    });
    const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
      baseUrl: "http://localhost:1234",
      password: "test",
    });
    expect(result).toEqual([]);
  });

  it("includes entries without a guid (downstream download handles filtering)", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () =>
        Promise.resolve({
          data: {
            attachments: [{ mimeType: "image/jpeg" }, { guid: "att-valid", mimeType: "image/png" }],
          },
        }),
    });
    const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
      baseUrl: "http://localhost:1234",
      password: "test",
    });
    expect(result).toHaveLength(2);
    expect(result[0].guid).toBeUndefined();
    expect(result[1].guid).toBe("att-valid");
  });
});

¤ Dauer der Verarbeitung: 0.26 Sekunden  (vorverarbeitet am  2026-04-27) ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

Beweissystem der NASA

Beweissystem Isabelle

NIST Cobol Testsuite

Cephes Mathematical Library

Wiener Entwicklungsmethode

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.






                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge