openclaw/src/line/send.test.ts

229 lines
6.8 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
pushMessageMock,
replyMessageMock,
showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
loadConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
} = vi.hoisted(() => {
const pushMessageMock = vi.fn();
const replyMessageMock = vi.fn();
const showLoadingAnimationMock = vi.fn();
const getProfileMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
const loadConfigMock = vi.fn(() => ({}));
const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" }));
const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token");
const recordChannelActivityMock = vi.fn();
const logVerboseMock = vi.fn();
return {
pushMessageMock,
replyMessageMock,
showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
loadConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
};
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.mock("./accounts.js", () => ({
resolveLineAccount: resolveLineAccountMock,
}));
vi.mock("./channel-access-token.js", () => ({
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
}));
vi.mock("../infra/channel-activity.js", () => ({
recordChannelActivity: recordChannelActivityMock,
}));
vi.mock("../globals.js", () => ({
logVerbose: logVerboseMock,
}));
let sendModule: typeof import("./send.js");
describe("LINE send helpers", () => {
beforeAll(async () => {
sendModule = await import("./send.js");
});
beforeEach(() => {
pushMessageMock.mockReset();
replyMessageMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockClear();
loadConfigMock.mockReset();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
loadConfigMock.mockReturnValue({});
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
pushMessageMock.mockResolvedValue({});
replyMessageMock.mockResolvedValue({});
showLoadingAnimationMock.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
});
it("limits quick reply items to 13", () => {
const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`);
const quickReply = sendModule.createQuickReplyItems(labels);
expect(quickReply.items).toHaveLength(13);
});
it("pushes images via normalized LINE target", async () => {
const result = await sendModule.pushImageMessage(
"line:user:U123",
"https://example.com/original.jpg",
undefined,
{ verbose: true },
);
expect(pushMessageMock).toHaveBeenCalledWith({
to: "U123",
messages: [
{
type: "image",
originalContentUrl: "https://example.com/original.jpg",
previewImageUrl: "https://example.com/original.jpg",
},
],
});
expect(recordChannelActivityMock).toHaveBeenCalledWith({
channel: "line",
accountId: "default",
direction: "outbound",
});
expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123");
expect(result).toEqual({ messageId: "push", chatId: "U123" });
});
it("replies when reply token is provided", async () => {
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
replyToken: "reply-token",
mediaUrl: "https://example.com/media.jpg",
verbose: true,
});
expect(replyMessageMock).toHaveBeenCalledTimes(1);
expect(pushMessageMock).not.toHaveBeenCalled();
expect(replyMessageMock).toHaveBeenCalledWith({
replyToken: "reply-token",
messages: [
{
type: "image",
originalContentUrl: "https://example.com/media.jpg",
previewImageUrl: "https://example.com/media.jpg",
},
{
type: "text",
text: "Hello",
},
],
});
expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1");
expect(result).toEqual({ messageId: "reply", chatId: "C1" });
});
it("throws when push messages are empty", async () => {
await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow(
"Message must be non-empty for LINE sends",
);
});
it("logs HTTP body when push fails", async () => {
const err = new Error("LINE push failed") as Error & {
status: number;
statusText: string;
body: string;
};
err.status = 400;
err.statusText = "Bad Request";
err.body = "invalid flex payload";
pushMessageMock.mockRejectedValueOnce(err);
await expect(
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }]),
).rejects.toThrow("LINE push failed");
expect(logVerboseMock).toHaveBeenCalledWith(
"line: push message failed (400 Bad Request): invalid flex payload",
);
});
it("caches profile results by default", async () => {
getProfileMock.mockResolvedValue({
displayName: "Peter",
pictureUrl: "https://example.com/peter.jpg",
});
const first = await sendModule.getUserProfile("U-cache");
const second = await sendModule.getUserProfile("U-cache");
expect(first).toEqual({
displayName: "Peter",
pictureUrl: "https://example.com/peter.jpg",
});
expect(second).toEqual(first);
expect(getProfileMock).toHaveBeenCalledTimes(1);
});
it("continues when loading animation is unsupported", async () => {
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
await expect(sendModule.showLoadingAnimation("line:room:R1")).resolves.toBeUndefined();
expect(logVerboseMock).toHaveBeenCalledWith(
expect.stringContaining("line: loading animation failed (non-fatal)"),
);
});
it("pushes quick-reply text and caps to 13 buttons", async () => {
await sendModule.pushTextMessageWithQuickReplies(
"U-quick",
"Pick one",
Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`),
);
expect(pushMessageMock).toHaveBeenCalledTimes(1);
const firstCall = pushMessageMock.mock.calls[0] as [
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
];
expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13);
});
});