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