Files
openclaw/extensions/line/src/send.test.ts
Peter Steinberger 0a146d5a18 fix(line): centralize Flex alternative-text bounds (#117217)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
2026-07-31 23:23:52 -07:00

1071 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { HTTPFetchError } from "@line/bot-sdk";
// Line tests cover send plugin behavior.
import { expectDefined } from "@openclaw/normalization-core";
import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
pushMessageMock,
replyMessageMock,
lineFetchMock,
showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
requireRuntimeConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
resolvePinnedHostnameWithPolicyMock,
} = vi.hoisted(() => {
const pushMessageMockLocal = vi.fn();
const replyMessageMockLocal = vi.fn();
const lineFetchMockLocal = vi.fn();
const showLoadingAnimationMockLocal = vi.fn();
const getProfileMockLocal = vi.fn();
const MessagingApiClientMockLocal = vi.fn(function () {
return {
pushMessage: pushMessageMockLocal,
replyMessage: replyMessageMockLocal,
showLoadingAnimation: showLoadingAnimationMockLocal,
getProfile: getProfileMockLocal,
};
});
const requireRuntimeConfigMockLocal = vi.fn((cfg: unknown) => cfg ?? {});
const resolveLineAccountMockLocal = vi.fn(() => ({ accountId: "default" }));
const resolveLineChannelAccessTokenMockLocal = vi.fn(() => "line-token");
const recordChannelActivityMockLocal = vi.fn();
const logVerboseMockLocal = vi.fn();
const resolvePinnedHostnameWithPolicyMockLocal = vi.fn();
return {
pushMessageMock: pushMessageMockLocal,
replyMessageMock: replyMessageMockLocal,
lineFetchMock: lineFetchMockLocal,
showLoadingAnimationMock: showLoadingAnimationMockLocal,
getProfileMock: getProfileMockLocal,
MessagingApiClientMock: MessagingApiClientMockLocal,
requireRuntimeConfigMock: requireRuntimeConfigMockLocal,
resolveLineAccountMock: resolveLineAccountMockLocal,
resolveLineChannelAccessTokenMock: resolveLineChannelAccessTokenMockLocal,
recordChannelActivityMock: recordChannelActivityMockLocal,
logVerboseMock: logVerboseMockLocal,
resolvePinnedHostnameWithPolicyMock: resolvePinnedHostnameWithPolicyMockLocal,
};
});
vi.mock("@line/bot-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@line/bot-sdk")>();
return {
...actual,
messagingApi: { ...actual.messagingApi, MessagingApiClient: MessagingApiClientMock },
};
});
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", () => ({
requireRuntimeConfig: requireRuntimeConfigMock,
}));
vi.mock("./accounts.js", () => ({
resolveLineAccount: resolveLineAccountMock,
}));
vi.mock("./channel-access-token.js", () => ({
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
}));
vi.mock("openclaw/plugin-sdk/channel-activity-runtime", () => ({
recordChannelActivity: recordChannelActivityMock,
}));
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
"openclaw/plugin-sdk/runtime-env",
);
return {
...actual,
logVerbose: logVerboseMock,
};
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
resolvePinnedHostnameWithPolicy: resolvePinnedHostnameWithPolicyMock,
}));
let sendModule: typeof import("./send.js");
const LINE_TEST_CFG = {
channels: {
line: {
accounts: {
default: {},
},
},
},
};
function createCredentialBearingHttpUrl(): string {
const url = new URL("http://example.com/image.jpg");
url.username = ["line", "user"].join("-");
url.password = ["line", "fixture"].join("-");
url.searchParams.set("auth", ["line", "query"].join("-"));
return url.href;
}
describe("LINE send helpers", () => {
const fixedSentAt = 1_800_000_000_000;
beforeAll(async () => {
sendModule = await import("./send.js");
});
afterAll(() => {
vi.doUnmock("@line/bot-sdk");
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
vi.doUnmock("./accounts.js");
vi.doUnmock("./channel-access-token.js");
vi.doUnmock("openclaw/plugin-sdk/channel-activity-runtime");
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
});
beforeEach(() => {
vi.setSystemTime(fixedSentAt);
pushMessageMock.mockReset();
replyMessageMock.mockReset();
lineFetchMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockReset();
requireRuntimeConfigMock.mockClear();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
resolvePinnedHostnameWithPolicyMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
requireRuntimeConfigMock.mockImplementation((cfg: unknown) => cfg ?? LINE_TEST_CFG);
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
hostname: "example.com",
addresses: ["93.184.216.34"],
});
pushMessageMock.mockResolvedValue({ sentMessages: [{ id: "push" }] });
replyMessageMock.mockResolvedValue({ sentMessages: [{ id: "reply" }] });
showLoadingAnimationMock.mockResolvedValue({});
lineFetchMock.mockImplementation(async (url: string | URL | Request, init?: RequestInit) => {
const requestUrl = typeof url === "string" ? url : url instanceof URL ? url.href : url.url;
if (typeof init?.body !== "string") {
throw new Error("LINE test fetch requires a JSON string request body");
}
const payload = JSON.parse(init.body);
const provider = requestUrl.endsWith("/push") ? pushMessageMock : replyMessageMock;
const body = await provider(payload);
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", lineFetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
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("counts quick reply labels in grapheme clusters", () => {
const label = "1234567890123456789😀";
const quickReply = sendModule.createQuickReplyItems([label]);
const item = quickReply.items?.[0] as { action: { label: string; text: string } } | undefined;
expect(item?.action.label).toBe(label);
expect(item?.action.text).toBe(label);
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(item?.action.label ?? "")).toBe(false);
});
it("keeps provider-valid Flex alternative text and bounds oversized Unicode safely", () => {
const contents = { type: "bubble" as const };
expect(sendModule.createFlexMessage("a".repeat(1200), contents).altText).toBe("a".repeat(1200));
const oversized = sendModule.createFlexMessage(`${"a".repeat(1499)}😀 overflow`, contents);
expect(oversized.altText).toBe("a".repeat(1499));
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(oversized.altText)).toBe(false);
});
it("sends the same provider-valid Flex alternative text through direct pushes", async () => {
const altText = "a".repeat(1200);
await sendModule.pushFlexMessage("U123", altText, { type: "bubble" }, { cfg: LINE_TEST_CFG });
expect(pushMessageMock).toHaveBeenCalledWith({
to: "U123",
messages: [{ type: "flex", altText, contents: { type: "bubble" } }],
});
});
it("normalizes raw Flex actions at both outbound API boundaries", async () => {
const oversizedPostback = { type: "postback", label: "Open", data: "x".repeat(301) };
const oversizedUri = {
type: "uri",
label: "Open",
uri: `https://e.example/?q=${"x".repeat(1200)}`,
};
const message = {
type: "flex",
altText: "Raw Flex",
contents: {
type: "bubble",
action: oversizedPostback,
hero: {
type: "video",
url: "https://e.example/video.mp4",
previewUrl: "https://e.example/preview.jpg",
altContent: {
type: "image",
url: "https://e.example/preview.jpg",
size: "full",
},
action: oversizedUri,
},
body: {
type: "box",
layout: "vertical",
action: oversizedPostback,
contents: [
{ type: "text", text: "Open", action: oversizedUri },
{ type: "button", action: oversizedPostback },
{
type: "text",
text: "Still works",
action: { type: "message", label: "Unavailable", text: "keep" },
},
],
},
},
};
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
await sendModule.replyMessageLine("reply-token", [message] as never, { cfg: LINE_TEST_CFG });
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
messages: Array<{ contents: Record<string, unknown> }>;
};
const replied = replyMessageMock.mock.calls[0]?.[0] as {
messages: Array<{ contents: Record<string, unknown> }>;
};
expect(pushed.messages[0]?.contents).toEqual(replied.messages[0]?.contents);
const contents = pushed.messages[0]?.contents as {
action?: unknown;
hero: { type: string; action?: unknown };
body: { action?: unknown; contents: Array<{ action?: unknown; text?: string }> };
};
const unavailableAction = {
type: "message",
label: "Unavailable",
text: "Action unavailable: callback data exceeds LINE's limit.",
};
expect(contents.action).toBeUndefined();
expect(contents.hero).toEqual({
type: "video",
url: "https://e.example/video.mp4",
previewUrl: "https://e.example/preview.jpg",
altContent: {
type: "image",
url: "https://e.example/preview.jpg",
size: "full",
},
});
expect(contents.body.action).toBeUndefined();
expect(contents.body.contents[0]?.action).toBeUndefined();
expect(contents.body.contents[1]?.action).toEqual(unavailableAction);
expect(contents.body.contents[2]?.action).toEqual({
type: "message",
label: "Unavailable",
text: "keep",
});
expect(contents.body.contents.slice(3).map((item) => item.text)).toEqual([
"Action unavailable: callback data exceeds LINE's limit.\nLink unavailable: URL exceeds LINE's limit.",
]);
expect(message.contents.action).toBe(oversizedPostback);
});
it("normalizes raw imagemap actions at both outbound API boundaries", async () => {
const area = { x: 0, y: 0, width: 520, height: 1040 };
const videoArea = { x: 520, y: 0, width: 520, height: 1040 };
const message = {
type: "imagemap",
baseUrl: "https://e.example/imagemap",
altText: "Map",
baseSize: { width: 1040, height: 1040 },
actions: [
{
type: "uri",
label: "Open",
linkUri: `https://e.example/?q=${"x".repeat(1200)}`,
area,
},
...Array.from({ length: 49 }, (_, index) => ({
type: "message",
label: `Item ${index}`,
text: `item-${index}`,
area,
})),
],
video: {
originalContentUrl: "https://e.example/video.mp4",
previewImageUrl: "https://e.example/preview.jpg",
area: videoArea,
externalLink: {
linkUri: `https://e.example/video?q=${"x".repeat(1200)}`,
label: "Open video",
},
},
};
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
await sendModule.replyMessageLine("reply-token", [message] as never, { cfg: LINE_TEST_CFG });
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
messages: Array<{ actions: unknown[]; video?: { externalLink?: unknown } }>;
};
const replied = replyMessageMock.mock.calls[0]?.[0] as {
messages: Array<{ actions: unknown[] }>;
};
expect(pushed.messages[0]?.actions).toEqual(replied.messages[0]?.actions);
expect(pushed.messages[0]?.actions).toHaveLength(50);
expect(pushed.messages[0]?.actions[0]).toEqual({
type: "message",
label: "Unavailable",
text: "Link unavailable: URL exceeds LINE's limit.",
area,
});
expect(pushed.messages[0]?.actions[1]).toMatchObject({
type: "message",
text: "item-0",
});
expect(pushed.messages[0]?.actions[49]).toMatchObject({
type: "message",
text: "item-48",
});
expect(pushed.messages[0]?.video?.externalLink).toBeUndefined();
expect(message.actions[0]?.type).toBe("uri");
});
it("counts imagemap message text in UTF-16 units at both outbound API boundaries", async () => {
const area = { x: 0, y: 0, width: 1040, height: 1040 };
const exactText = "😀".repeat(200);
const message = {
type: "imagemap",
baseUrl: "https://e.example/imagemap",
altText: "Map",
baseSize: { width: 1040, height: 1040 },
actions: [
{ type: "message", label: "Exact", text: exactText, area },
{ type: "message", label: "Too long", text: `${exactText}😀`, area },
],
};
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
await sendModule.replyMessageLine("reply-token", [message] as never, { cfg: LINE_TEST_CFG });
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
messages: Array<{ actions: unknown[] }>;
};
const replied = replyMessageMock.mock.calls[0]?.[0] as {
messages: Array<{ actions: unknown[] }>;
};
expect(pushed.messages[0]?.actions).toEqual(replied.messages[0]?.actions);
expect(pushed.messages[0]?.actions).toEqual([
{ type: "message", label: "Exact", text: exactText, area },
{
type: "message",
label: "Unavailable",
text: "Action unavailable: message text exceeds LINE's limit.",
area,
},
]);
});
it("counts imagemap video-link labels in UTF-16 units", async () => {
const message = {
type: "imagemap",
baseUrl: "https://e.example/imagemap",
altText: "Map",
baseSize: { width: 1040, height: 1040 },
actions: [],
video: {
originalContentUrl: "https://e.example/video.mp4",
previewImageUrl: "https://e.example/preview.jpg",
area: { x: 0, y: 0, width: 1040, height: 1040 },
externalLink: {
linkUri: "https://e.example/video",
label: "😀".repeat(16),
},
},
};
await sendModule.pushMessagesLine("U123", [message] as never, { cfg: LINE_TEST_CFG });
const pushed = pushMessageMock.mock.calls[0]?.[0] as {
messages: Array<{ video: { externalLink: { label: string } } }>;
};
expect(pushed.messages[0]?.video.externalLink.label).toBe("😀".repeat(15));
});
it("pushes images via normalized LINE target", async () => {
const result = await sendModule.pushImageMessage(
"line:user:U123",
"https://example.com/original.jpg",
undefined,
{ cfg: LINE_TEST_CFG, 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({
chatId: "U123",
messageId: "push",
receipt: {
parts: [
{
index: 0,
kind: "media",
platformMessageId: "push",
raw: {
channel: "line",
chatId: "U123",
conversationId: "U123",
messageId: "push",
meta: { messageCount: 1 },
},
threadId: "U123",
},
],
platformMessageIds: ["push"],
primaryPlatformMessageId: "push",
raw: [
{
channel: "line",
chatId: "U123",
conversationId: "U123",
messageId: "push",
meta: { messageCount: 1 },
},
],
sentAt: fixedSentAt,
threadId: "U123",
},
});
});
it("preserves every provider message id returned by a LINE push", async () => {
pushMessageMock.mockResolvedValueOnce({
sentMessages: [{ id: "613452345678901234" }, { id: "613452345678901235" }],
});
const result = await sendModule.pushMessagesLine(
"line:user:U123",
[
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
{ cfg: LINE_TEST_CFG },
);
expect(result.messageId).toBe("613452345678901234");
expect(result.receipt.platformMessageIds).toEqual(["613452345678901234", "613452345678901235"]);
});
it("replies when reply token is provided", async () => {
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
cfg: LINE_TEST_CFG,
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({
chatId: "C1",
messageId: "reply",
receipt: {
parts: [
{
index: 0,
kind: "media",
platformMessageId: "reply",
raw: {
channel: "line",
chatId: "C1",
conversationId: "C1",
messageId: "reply",
meta: { messageCount: 2 },
},
threadId: "C1",
},
],
platformMessageIds: ["reply"],
primaryPlatformMessageId: "reply",
raw: [
{
channel: "line",
chatId: "C1",
conversationId: "C1",
messageId: "reply",
meta: { messageCount: 2 },
},
],
sentAt: fixedSentAt,
threadId: "C1",
},
});
});
it("preserves every provider message id returned by a LINE reply", async () => {
replyMessageMock.mockResolvedValueOnce({
sentMessages: [{ id: "713452345678901234" }, { id: "713452345678901235" }],
});
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
cfg: LINE_TEST_CFG,
replyToken: "reply-token",
mediaUrl: "https://example.com/media.jpg",
});
expect(result.messageId).toBe("713452345678901234");
expect(result.receipt.platformMessageIds).toEqual(["713452345678901234", "713452345678901235"]);
});
it.each([
{
label: "push",
send: () => sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG }),
provider: pushMessageMock,
},
{
label: "reply",
send: () =>
sendModule.sendMessageLine("U123", "Hello", {
cfg: LINE_TEST_CFG,
replyToken: "reply-token",
}),
provider: replyMessageMock,
},
])("preserves a finalized $label when activity recording fails", async ({ send, provider }) => {
provider.mockResolvedValueOnce({ sentMessages: [{ id: "line-provider-final" }] });
recordChannelActivityMock.mockImplementationOnce(() => {
throw new Error("activity store unavailable");
});
let caught: unknown;
try {
await send();
} catch (error) {
caught = error;
}
expect(isChannelPartialDeliveryError(caught)).toBe(true);
if (!isChannelPartialDeliveryError(caught)) {
throw new Error("expected a partial LINE delivery error");
}
expect(caught.deliveryResult).toMatchObject({
messageIds: ["line-provider-final"],
receipt: {
primaryPlatformMessageId: "line-provider-final",
platformMessageIds: ["line-provider-final"],
threadId: "U123",
parts: [
{
platformMessageId: "line-provider-final",
kind: "text",
raw: { chatId: "U123", meta: { messageCount: 1 } },
},
],
},
visibleReplySent: true,
});
});
it.each([
{
label: "push",
send: () => sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG }),
provider: pushMessageMock,
},
{
label: "reply",
send: () =>
sendModule.sendMessageLine("U123", "Hello", {
cfg: LINE_TEST_CFG,
replyToken: "reply-token",
}),
provider: replyMessageMock,
},
])(
"preserves accepted delivery when a $label response omits its provider message ids",
async ({ send, provider }) => {
provider.mockResolvedValueOnce({ sentMessages: [] });
let caught: unknown;
try {
await send();
} catch (error) {
caught = error;
}
expect(isChannelPartialDeliveryError(caught)).toBe(true);
if (!isChannelPartialDeliveryError(caught)) {
throw new Error("expected an accepted LINE delivery without an identity");
}
expect(caught.deliveryResult).toEqual({ messageIds: [], visibleReplySent: true });
expect(recordChannelActivityMock).not.toHaveBeenCalled();
},
);
it.each([
{ operation: "push", body: "{" },
{ operation: "push", body: "" },
{ operation: "reply", body: "{" },
{ operation: "reply", body: "" },
])("does not retry an accepted $operation response with an unreadable receipt", async (input) => {
lineFetchMock.mockResolvedValueOnce(
new Response(input.body, {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const send =
input.operation === "push"
? () => sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG })
: () =>
sendModule.sendMessageLine("U123", "Hello", {
cfg: LINE_TEST_CFG,
replyToken: "reply-token",
});
let caught: unknown;
try {
await send();
} catch (error) {
caught = error;
}
expect(isChannelPartialDeliveryError(caught)).toBe(true);
if (!isChannelPartialDeliveryError(caught)) {
throw new Error("expected an accepted LINE delivery without a readable identity");
}
expect(caught.deliveryResult).toEqual({ messageIds: [], visibleReplySent: true });
expect(lineFetchMock).toHaveBeenCalledOnce();
expect(recordChannelActivityMock).not.toHaveBeenCalled();
});
it("keeps rejected LINE sends distinguishable from accepted delivery", async () => {
lineFetchMock.mockResolvedValueOnce(
new Response("invalid payload", { status: 400, statusText: "Bad Request" }),
);
let caught: unknown;
try {
await sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(HTTPFetchError);
expect(isChannelPartialDeliveryError(caught)).toBe(false);
expect(caught).toMatchObject({
status: 400,
statusText: "Bad Request",
body: "invalid payload",
});
});
it("does not misclassify network SyntaxErrors as provider acceptance", async () => {
const failure = new SyntaxError("upstream network decoder failed");
lineFetchMock.mockRejectedValueOnce(failure);
await expect(sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG })).rejects.toBe(
failure,
);
expect(isChannelPartialDeliveryError(failure)).toBe(false);
});
it.each([
{
label: "push",
send: () =>
sendModule.pushMessagesLine("U123", [{ type: "text", text: "Hello" }], {
cfg: LINE_TEST_CFG,
}),
provider: pushMessageMock,
},
{
label: "reply",
send: () =>
sendModule.sendMessageLine("U123", "Hello", {
cfg: LINE_TEST_CFG,
replyToken: "reply-token",
}),
provider: replyMessageMock,
},
])(
"retains valid identities from a partially invalid accepted $label response",
async ({ send, provider }) => {
provider.mockResolvedValueOnce({
sentMessages: [{ id: "line-provider-delivered" }, { id: " " }],
});
let caught: unknown;
try {
await send();
} catch (error) {
caught = error;
}
expect(isChannelPartialDeliveryError(caught)).toBe(true);
if (!isChannelPartialDeliveryError(caught)) {
throw new Error("expected a partially identifiable accepted LINE delivery");
}
expect(caught.deliveryResult).toEqual({
messageIds: ["line-provider-delivered"],
visibleReplySent: true,
});
expect(recordChannelActivityMock).not.toHaveBeenCalled();
},
);
it.each([
{ name: "object receipt container", sentMessages: {} },
{ name: "null receipt entry", sentMessages: [null] },
{ name: "missing entry identifier", sentMessages: [{}] },
])("preserves accepted delivery for a malformed $name", async ({ sentMessages }) => {
pushMessageMock.mockResolvedValueOnce({ sentMessages });
let caught: unknown;
try {
await sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG });
} catch (error) {
caught = error;
}
expect(isChannelPartialDeliveryError(caught)).toBe(true);
if (!isChannelPartialDeliveryError(caught)) {
throw new Error("expected an accepted LINE delivery with a malformed receipt");
}
expect(caught.deliveryResult).toEqual({ messageIds: [], visibleReplySent: true });
expect(pushMessageMock).toHaveBeenCalledOnce();
expect(recordChannelActivityMock).not.toHaveBeenCalled();
});
it("preserves literal internal-looking text in low-level sends", async () => {
const text = "⚠️ 🛠️ `search repos (agent)` failed";
await sendModule.sendMessageLine("line:user:U123", text, { cfg: LINE_TEST_CFG });
expect(pushMessageMock).toHaveBeenCalledWith({
to: "U123",
messages: [{ type: "text", text }],
});
});
it("sends video with explicit image preview URL", async () => {
await sendModule.sendMessageLine("line:user:U100", "Video", {
cfg: LINE_TEST_CFG,
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-1",
});
expect(pushMessageMock).toHaveBeenCalledWith({
to: "U100",
messages: [
{
type: "video",
originalContentUrl: "https://example.com/video.mp4",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-1",
},
{
type: "text",
text: "Video",
},
],
});
});
it("throws when video preview URL is missing", async () => {
await expect(
sendModule.sendMessageLine("line:user:U200", "Video", {
cfg: LINE_TEST_CFG,
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
}),
).rejects.toThrow(/require previewimageurl/i);
});
it("blocks private-network media URLs before calling LINE", async () => {
resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce(
new Error("SSRF blocked private network target"),
);
await expect(
sendModule.sendMessageLine("line:user:U200", "Image", {
cfg: LINE_TEST_CFG,
mediaUrl: "https://127.0.0.1/image.jpg",
}),
).rejects.toThrow(/private network/i);
expect(pushMessageMock).not.toHaveBeenCalled();
});
it.each([
{
name: "send media URL",
run: () =>
sendModule.sendMessageLine("line:user:U200", "Image", {
cfg: LINE_TEST_CFG,
mediaUrl: createCredentialBearingHttpUrl(),
}),
},
{
name: "send preview URL",
run: () =>
sendModule.sendMessageLine("line:user:U200", "Video", {
cfg: LINE_TEST_CFG,
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
previewImageUrl: createCredentialBearingHttpUrl(),
}),
},
{
name: "push image URL",
run: () =>
sendModule.pushImageMessage("line:user:U200", createCredentialBearingHttpUrl(), undefined, {
cfg: LINE_TEST_CFG,
}),
},
{
name: "push image preview URL",
run: () =>
sendModule.pushImageMessage(
"line:user:U200",
"https://example.com/image.jpg",
createCredentialBearingHttpUrl(),
{ cfg: LINE_TEST_CFG },
),
},
])("does not expose credentials from an insecure $name", async ({ run }) => {
await expect(run()).rejects.toThrow(new Error("LINE outbound media URL must use HTTPS"));
expect(pushMessageMock).not.toHaveBeenCalled();
expect(replyMessageMock).not.toHaveBeenCalled();
});
it("omits trackingId for non-user destinations", async () => {
await sendModule.sendMessageLine("line:group:C100", "Video", {
cfg: LINE_TEST_CFG,
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-group",
});
expect(pushMessageMock).toHaveBeenCalledWith({
to: "C100",
messages: [
{
type: "video",
originalContentUrl: "https://example.com/video.mp4",
previewImageUrl: "https://example.com/preview.jpg",
},
{
type: "text",
text: "Video",
},
],
});
});
it("throws when push messages are empty", async () => {
await expect(sendModule.pushMessagesLine("U123", [], { cfg: LINE_TEST_CFG })).rejects.toThrow(
"Message must be non-empty for LINE sends",
);
});
it("rejects lowercased LINE-shaped recipients (#81628 safety net)", async () => {
// 33-char value with lowercase leading char — what an upstream session-key
// fragment looked like before the cron-tool fix. LINE rejects with HTTP 400
// anyway; throwing locally keeps the failure permanent so delivery-recovery
// moves the entry to failed/ immediately instead of silently retrying 5×.
await expect(
sendModule.pushMessagesLine(
"cabcdef0123456789abcdef0123456789",
[{ type: "text", text: "hello" }],
{ cfg: LINE_TEST_CFG },
),
).rejects.toThrow(/Recipient is not a valid LINE id/);
expect(pushMessageMock).not.toHaveBeenCalled();
});
it("preserves UTF-16 boundaries in invalid recipient diagnostics", async () => {
await expect(
sendModule.pushMessagesLine(`aab😀${"x".repeat(40)}`, [{ type: "text", text: "hello" }], {
cfg: LINE_TEST_CFG,
}),
).rejects.toThrow(
"Recipient is not a valid LINE id (case-sensitive; expected leading capital C/U/R): aab…",
);
await expect(
sendModule.pushMessagesLine(`aa😀${"y".repeat(40)}`, [{ type: "text", text: "hello" }], {
cfg: LINE_TEST_CFG,
}),
).rejects.toThrow(
"Recipient is not a valid LINE id (case-sensitive; expected leading capital C/U/R): aa😀…",
);
expect(pushMessageMock).not.toHaveBeenCalled();
});
it("accepts case-exact LINE recipients with the leading capital preserved", async () => {
await sendModule.pushMessagesLine(
"Cabcdef0123456789abcdef0123456789",
[{ type: "text", text: "hello" }],
{ cfg: LINE_TEST_CFG },
);
expect(pushMessageMock).toHaveBeenCalledWith({
to: "Cabcdef0123456789abcdef0123456789",
messages: [{ type: "text", text: "hello" }],
});
});
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" }], {
cfg: LINE_TEST_CFG,
}),
).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", { cfg: LINE_TEST_CFG });
const second = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
expect(first).toEqual({
displayName: "Peter",
pictureUrl: "https://example.com/peter.jpg",
});
expect(second).toEqual(first);
expect(getProfileMock).toHaveBeenCalledTimes(1);
});
it("bounds profile cache entries across distinct users", async () => {
getProfileMock.mockImplementation(async (userId: string) => ({
displayName: userId,
}));
for (let index = 0; index <= 1000; index += 1) {
await sendModule.getUserProfile(`U-profile-${index}`, { cfg: LINE_TEST_CFG });
}
await sendModule.getUserProfile("U-profile-0", { cfg: LINE_TEST_CFG });
expect(getProfileMock).toHaveBeenCalledTimes(1002);
});
it("continues when loading animation is unsupported", async () => {
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
await expect(
sendModule.showLoadingAnimation("line:room:R1", { cfg: LINE_TEST_CFG }),
).resolves.toBeUndefined();
expect(logVerboseMock).toHaveBeenCalledWith(
"line: loading animation failed (non-fatal): Error: unsupported",
);
});
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}`),
{ cfg: LINE_TEST_CFG },
);
expect(pushMessageMock).toHaveBeenCalledTimes(1);
const firstCall = pushMessageMock.mock.calls.at(0) as [
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
];
const payload = expectDefined(firstCall[0], "LINE push payload");
expect(expectDefined(payload.messages[0], "LINE push message").quickReply?.items).toHaveLength(
13,
);
});
});