Files
openclaw/extensions/telegram/src/send.test-harness.ts
Peter Steinberger d258c6651a refactor: make dense test fixtures type-safe (#124625)
* test: replace assertion chains with typed fixture builders

* test: avoid generic mock lint suppressions

* test: preserve Telegram API mock signatures
2026-08-16 09:22:27 -07:00

325 lines
9.4 KiB
TypeScript

// Telegram plugin module implements send harness behavior.
import type { Bot } from "grammy";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import {
buildOutboundMediaLoadOptions,
normalizePollInput,
} from "openclaw/plugin-sdk/media-runtime";
import type { MockFn } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, vi } from "vitest";
import { markdownToTelegramHtml } from "./format.js";
import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-block-model.js";
type TelegramApiMethod = (...args: never[]) => unknown;
type TelegramApiTestOverrides = {
[Key in keyof Bot["api"] as Bot["api"][Key] extends TelegramApiMethod ? Key : never]?: MockFn<
Extract<Bot["api"][Key], TelegramApiMethod>
>;
};
export function makeTelegramApiTestMock<Overrides extends TelegramApiTestOverrides>(
overrides: Overrides,
): Overrides & Partial<Bot["api"]> {
return overrides as Overrides & Partial<Bot["api"]>;
}
export function makeTelegramInvalidApiResultMock<Key extends keyof Bot["api"]>(
_method: Key,
implementation: (...args: Parameters<Extract<Bot["api"][Key], TelegramApiMethod>>) => unknown,
): MockFn<Extract<Bot["api"][Key], TelegramApiMethod>> {
return vi.fn(implementation) as ReturnType<typeof vi.fn> &
MockFn<Extract<Bot["api"][Key], TelegramApiMethod>>;
}
function richMessagePlainTextForTest(richMessage: {
blocks?: InputRichBlock[];
markdown?: string;
html?: string;
}): string {
if (richMessage.blocks) {
return inputRichBlocksToPlainText(richMessage.blocks);
}
if (richMessage.markdown !== undefined) {
return markdownToTelegramHtml(richMessage.markdown);
}
return richMessage.html ?? "";
}
const { botApi, botRawApi, botConfigUseSpy, botCtorSpy } = vi.hoisted(() => ({
botConfigUseSpy: vi.fn(),
botRawApi: {
editMessageText: vi.fn(),
sendRichMessage: vi.fn(),
},
botApi: {
deleteMessage: vi.fn(),
editForumTopic: vi.fn(),
editMessageCaption: vi.fn(),
editMessageText: vi.fn(),
editMessageReplyMarkup: vi.fn(),
getChatMember: vi.fn(),
pinChatMessage: vi.fn(),
sendChatAction: vi.fn(),
sendMessage: vi.fn(),
sendPoll: vi.fn(),
sendPhoto: vi.fn(),
sendVoice: vi.fn(),
sendAudio: vi.fn(),
sendVideo: vi.fn(),
sendVideoNote: vi.fn(),
sendAnimation: vi.fn(),
setMessageReaction: vi.fn(),
sendSticker: vi.fn(),
unpinChatMessage: vi.fn(),
},
botCtorSpy: vi.fn(),
}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
}));
const { imageMetadata } = vi.hoisted(() => ({
imageMetadata: {
width: 1200 as number | undefined,
height: 800 as number | undefined,
},
}));
const { probeVideoDimensions } = vi.hoisted(() => ({
probeVideoDimensions: vi.fn(),
}));
const { loadConfig, resolveStorePath } = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
resolveStorePath: vi.fn(
(storePath?: string) => storePath ?? "/tmp/openclaw-telegram-send-tests.json",
),
}));
const { maybePersistResolvedTelegramTarget } = vi.hoisted(() => ({
maybePersistResolvedTelegramTarget: vi.fn(async () => {}),
}));
const {
undiciFetch,
undiciSetGlobalDispatcher,
undiciAgentCtor,
undiciEnvHttpProxyAgentCtor,
undiciProxyAgentCtor,
} = vi.hoisted(() => ({
undiciFetch: vi.fn(),
undiciSetGlobalDispatcher: vi.fn(),
undiciAgentCtor: vi.fn(function MockAgent(
this: { options?: Record<string, unknown> },
options?: Record<string, unknown>,
) {
this.options = options;
}),
undiciEnvHttpProxyAgentCtor: vi.fn(function MockEnvHttpProxyAgent(
this: { options?: Record<string, unknown> },
options?: Record<string, unknown>,
) {
this.options = options;
}),
undiciProxyAgentCtor: vi.fn(function MockProxyAgent(
this: { options?: Record<string, unknown> | string },
options?: Record<string, unknown> | string,
) {
this.options = options;
}),
}));
type TelegramSendTestMocks = {
botApi: typeof botApi;
botRawApi: typeof botRawApi;
botConfigUseSpy: MockFn;
botCtorSpy: MockFn;
loadConfig: MockFn;
resolveStorePath: MockFn;
loadWebMedia: MockFn;
maybePersistResolvedTelegramTarget: MockFn;
imageMetadata: { width: number | undefined; height: number | undefined };
probeVideoDimensions: MockFn;
};
vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia,
}));
vi.mock("grammy", () => ({
API_CONSTANTS: {
DEFAULT_UPDATE_TYPES: ["message"],
ALL_UPDATE_TYPES: ["message"],
},
Bot: class {
api = {
...botApi,
raw: botRawApi,
config: {
use: botConfigUseSpy,
},
};
catch = vi.fn();
constructor(
public token: string,
public options?: {
client?: { fetch?: typeof fetch; timeoutSeconds?: number };
},
) {
botCtorSpy(token, options);
}
},
HttpError: class HttpError extends Error {
constructor(
message = "HttpError",
public error?: unknown,
) {
super(message);
}
},
GrammyError: class GrammyError extends Error {
description = "";
},
InputFile: class InputFile {
constructor(
public readonly fileData: Buffer,
public readonly filename?: string,
) {}
},
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
Agent: undiciAgentCtor,
EnvHttpProxyAgent: undiciEnvHttpProxyAgentCtor,
ProxyAgent: undiciProxyAgentCtor,
fetch: undiciFetch,
setGlobalDispatcher: undiciSetGlobalDispatcher,
};
});
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-config-runtime")>(
"openclaw/plugin-sdk/plugin-config-runtime",
);
return {
...actual,
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfig()),
};
});
vi.mock("./send.runtime.js", () => ({
buildOutboundMediaLoadOptions,
getImageMetadata: vi.fn(async () => ({ ...imageMetadata })),
loadConfig,
loadWebMedia,
normalizePollInput,
probeVideoDimensions,
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfig()),
resolveMarkdownTableMode,
resolveStorePath,
}));
vi.mock("./target-writeback.js", () => ({
maybePersistResolvedTelegramTarget,
}));
export function getTelegramSendTestMocks(): TelegramSendTestMocks {
return {
botApi,
botRawApi,
botConfigUseSpy,
botCtorSpy,
loadConfig,
resolveStorePath,
loadWebMedia,
maybePersistResolvedTelegramTarget,
imageMetadata,
probeVideoDimensions,
};
}
export function installTelegramSendTestHooks() {
beforeEach(() => {
loadConfig.mockReturnValue({});
resolveStorePath.mockReturnValue("/tmp/openclaw-telegram-send-tests.json");
loadWebMedia.mockReset();
probeVideoDimensions.mockReset();
probeVideoDimensions.mockResolvedValue(undefined);
imageMetadata.width = 1200;
imageMetadata.height = 800;
maybePersistResolvedTelegramTarget.mockReset();
maybePersistResolvedTelegramTarget.mockResolvedValue(undefined);
undiciFetch.mockReset();
undiciSetGlobalDispatcher.mockReset();
undiciAgentCtor.mockClear();
undiciEnvHttpProxyAgentCtor.mockClear();
undiciProxyAgentCtor.mockClear();
botCtorSpy.mockReset();
botConfigUseSpy.mockReset();
for (const fn of Object.values(botApi)) {
fn.mockReset();
}
for (const fn of Object.values(botRawApi)) {
fn.mockReset();
}
botRawApi.sendRichMessage.mockImplementation(
async (params: {
chat_id: string | number;
rich_message: { markdown?: string; html?: string; skip_entity_detection?: boolean };
[key: string]: unknown;
}) => {
const { chat_id, rich_message, ...richParams } = params;
const sendParams: Record<string, unknown> = {
parse_mode: "HTML",
...(rich_message.skip_entity_detection === true ? { skip_entity_detection: true } : {}),
...richParams,
};
const replyParameters = sendParams.reply_parameters;
if (
replyParameters &&
typeof replyParameters === "object" &&
!("quote" in replyParameters) &&
typeof (replyParameters as { message_id?: unknown }).message_id === "number"
) {
sendParams.reply_to_message_id = (replyParameters as { message_id: number }).message_id;
sendParams.allow_sending_without_reply = true;
delete sendParams.reply_parameters;
}
const text = richMessagePlainTextForTest(rich_message);
const options = Object.keys(sendParams).length > 0 ? sendParams : undefined;
return await botApi.sendMessage(chat_id, text, options);
},
);
botRawApi.editMessageText.mockImplementation(
async (params: {
chat_id?: string | number;
message_id?: number;
rich_message: {
blocks?: InputRichBlock[];
markdown?: string;
html?: string;
skip_entity_detection?: boolean;
};
[key: string]: unknown;
}) => {
const { chat_id, message_id, rich_message, ...editParams } = params;
const text = richMessagePlainTextForTest(rich_message);
const options = {
...(rich_message.skip_entity_detection === true ? { skip_entity_detection: true } : {}),
...editParams,
};
return await botApi.editMessageText(chat_id, message_id, text, options);
},
);
});
}
export async function importTelegramSendModule() {
vi.resetModules();
return await import("./send.js");
}