feat: 初始化 Gitea Action 发送钉钉机器人消息项目
This commit is contained in:
592
tests/unit/config.test.js
Normal file
592
tests/unit/config.test.js
Normal file
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* config.js 模块单元测试
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
logConfig,
|
||||
parseConfig,
|
||||
validateMessageType,
|
||||
validateWebhookUrl,
|
||||
} from '../../src/config.js';
|
||||
import { envConfigs } from '../fixtures/configs/valid-configs.js';
|
||||
import { assertConfigStructure } from '../helpers/assertions.js';
|
||||
import { mockEnvVars } from '../helpers/env-mock.js';
|
||||
|
||||
describe('测试 config.js 文件', () => {
|
||||
let restoreEnv;
|
||||
let consoleSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock console methods
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore console methods
|
||||
Object.values(consoleSpy).forEach(spy => spy.mockRestore());
|
||||
|
||||
// Restore environment variables
|
||||
if (restoreEnv) {
|
||||
restoreEnv();
|
||||
restoreEnv = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('测试 MESSAGE_TYPES 常量', () => {
|
||||
it('应该导出正确的消息类型', () => {
|
||||
expect(MESSAGE_TYPES).toEqual({
|
||||
TEXT: 'text',
|
||||
MARKDOWN: 'markdown',
|
||||
LINK: 'link',
|
||||
ACTION_CARD: 'actionCard',
|
||||
FEED_CARD: 'feedCard',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该是只读枚举', () => {
|
||||
expect(() => {
|
||||
MESSAGE_TYPES.NEW_TYPE = 'newType';
|
||||
}).not.toThrow(); // JavaScript doesn't prevent this, but it's documented as readonly
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 validateWebhookUrl 函数', () => {
|
||||
it('应该验证正确的钉钉 Webhook URL', () => {
|
||||
const validUrls = [
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=abc123',
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=test-token-123',
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=1234567890abcdef',
|
||||
];
|
||||
|
||||
validUrls.forEach(url => {
|
||||
expect(validateWebhookUrl(url)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝无效的 Webhook URL', () => {
|
||||
const invalidUrls = [
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
'not-a-url',
|
||||
'http://oapi.dingtalk.com/robot/send?access_token=abc123', // http instead of https
|
||||
'https://wrong-domain.com/robot/send?access_token=abc123',
|
||||
'https://oapi.dingtalk.com/wrong/path?access_token=abc123',
|
||||
'https://oapi.dingtalk.com/robot/send', // missing access_token
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=', // empty token
|
||||
'https://oapi.dingtalk.com/robot/send?wrong_param=abc123',
|
||||
];
|
||||
|
||||
invalidUrls.forEach(url => {
|
||||
expect(validateWebhookUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理非字符串输入', () => {
|
||||
expect(validateWebhookUrl(123)).toBe(false);
|
||||
expect(validateWebhookUrl({})).toBe(false);
|
||||
expect(validateWebhookUrl([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 validateMessageType 函数', () => {
|
||||
it('应该验证支持的消息类型', () => {
|
||||
Object.values(MESSAGE_TYPES).forEach(type => {
|
||||
expect(validateMessageType(type)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝不支持的消息类型', () => {
|
||||
const invalidTypes = [
|
||||
'unsupported',
|
||||
'TEXT', // wrong case
|
||||
'MARKDOWN', // wrong case
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
123,
|
||||
];
|
||||
|
||||
invalidTypes.forEach(type => {
|
||||
expect(validateMessageType(type)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 parseConfig 函数', () => {
|
||||
describe('有效配置测试', () => {
|
||||
it('应该解析基础文本消息配置', async () => {
|
||||
restoreEnv = mockEnvVars(envConfigs.basicText);
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
assertConfigStructure(config, ['webhookUrl', 'messageType', 'content']);
|
||||
expect(config.messageType).toBe('text');
|
||||
expect(config.content).toBe('Hello, World!');
|
||||
expect(config.atAll).toBe(false);
|
||||
expect(config.atMobiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该解析 Markdown 消息配置', async () => {
|
||||
restoreEnv = mockEnvVars(envConfigs.markdown);
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config.messageType).toBe('markdown');
|
||||
expect(config.title).toBe('Markdown 标题');
|
||||
expect(config.content).toContain('Markdown');
|
||||
});
|
||||
|
||||
it('应该解析带 @all 的文本消息配置', async () => {
|
||||
restoreEnv = mockEnvVars(envConfigs.textWithAtAll);
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config.atAll).toBe(true);
|
||||
expect(config.messageType).toBe('text');
|
||||
});
|
||||
|
||||
it('应该正确解析手机号码', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
...envConfigs.basicText,
|
||||
INPUT_AT_MOBILES: '13800138000,13900139000,invalid-mobile,15012345678',
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config.atMobiles).toEqual(['13800138000', '13900139000', '15012345678']);
|
||||
});
|
||||
|
||||
it('应该解析链接消息配置', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'link',
|
||||
INPUT_CONTENT: '链接描述',
|
||||
INPUT_TITLE: '链接标题',
|
||||
INPUT_LINK_URL: 'https://example.com',
|
||||
INPUT_PIC_URL: 'https://example.com/image.jpg',
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config.messageType).toBe('link');
|
||||
expect(config.title).toBe('链接标题');
|
||||
expect(config.linkUrl).toBe('https://example.com');
|
||||
expect(config.picUrl).toBe('https://example.com/image.jpg');
|
||||
});
|
||||
|
||||
it('应该解析单按钮 ActionCard 配置', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard 内容',
|
||||
INPUT_TITLE: 'ActionCard 标题',
|
||||
INPUT_BTN_ORIENTATION: '0',
|
||||
INPUT_SINGLE_TITLE: '查看详情',
|
||||
INPUT_SINGLE_URL: 'https://example.com/detail',
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config.messageType).toBe('actionCard');
|
||||
expect(config.btnOrientation).toBe('0');
|
||||
expect(config.singleTitle).toBe('查看详情');
|
||||
expect(config.singleURL).toBe('https://example.com/detail');
|
||||
expect(config.buttons).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该解析多按钮 ActionCard 配置', async () => {
|
||||
const buttons = JSON.stringify([
|
||||
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||
]);
|
||||
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard 内容',
|
||||
INPUT_TITLE: 'ActionCard 标题',
|
||||
INPUT_BTN_ORIENTATION: '1',
|
||||
INPUT_BUTTONS: buttons,
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config.messageType).toBe('actionCard');
|
||||
expect(config.btnOrientation).toBe('1');
|
||||
expect(config.buttons).toHaveLength(2);
|
||||
expect(config.buttons[0]).toEqual({
|
||||
title: '按钮1',
|
||||
actionURL: 'https://example.com/action1',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该解析 FeedCard 配置', async () => {
|
||||
const feedLinks = JSON.stringify([
|
||||
{
|
||||
title: '新闻1',
|
||||
messageURL: 'https://example.com/news1',
|
||||
picURL: 'https://example.com/pic1.jpg',
|
||||
},
|
||||
{
|
||||
title: '新闻2',
|
||||
messageURL: 'https://example.com/news2',
|
||||
picURL: 'https://example.com/pic2.jpg',
|
||||
},
|
||||
]);
|
||||
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||
INPUT_CONTENT: 'FeedCard content',
|
||||
INPUT_FEED_LINKS: feedLinks,
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config.messageType).toBe('feedCard');
|
||||
expect(config.feedLinks).toHaveLength(2);
|
||||
expect(config.feedLinks[0]).toEqual({
|
||||
title: '新闻1',
|
||||
messageURL: 'https://example.com/news1',
|
||||
picURL: 'https://example.com/pic1.jpg',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('无效配置测试', () => {
|
||||
it('应该抛出缺少 Webhook URL 的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: '',
|
||||
INPUT_MESSAGE_TYPE: 'text',
|
||||
INPUT_CONTENT: 'Hello, World!',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('webhook_url is required');
|
||||
});
|
||||
|
||||
it('应该抛出无效 Webhook URL 格式的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'not-a-valid-url',
|
||||
INPUT_MESSAGE_TYPE: 'text',
|
||||
INPUT_CONTENT: 'Hello, World!',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('webhook_url format is invalid');
|
||||
});
|
||||
|
||||
it('应该抛出缺少内容的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'text',
|
||||
INPUT_CONTENT: '',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('content is required');
|
||||
});
|
||||
|
||||
it('应该抛出不支持的消息类型错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'unsupported',
|
||||
INPUT_CONTENT: 'Hello, World!',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('message_type must be one of');
|
||||
});
|
||||
|
||||
it('应该抛出 Markdown 消息缺少标题的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'markdown',
|
||||
INPUT_CONTENT: '## Content',
|
||||
INPUT_TITLE: '',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('title is required for markdown message type');
|
||||
});
|
||||
|
||||
it('应该抛出 Link 消息缺少标题的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'link',
|
||||
INPUT_CONTENT: 'Link description',
|
||||
INPUT_TITLE: '',
|
||||
INPUT_LINK_URL: 'https://example.com',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('title is required for link message type');
|
||||
});
|
||||
|
||||
it('应该抛出缺少链接 URL 的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'link',
|
||||
INPUT_CONTENT: 'Link description',
|
||||
INPUT_TITLE: 'Link title',
|
||||
INPUT_LINK_URL: '',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('link_url is required for link message type');
|
||||
});
|
||||
|
||||
it('应该抛出无效链接 URL 的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'link',
|
||||
INPUT_CONTENT: 'Link description',
|
||||
INPUT_TITLE: 'Link title',
|
||||
INPUT_LINK_URL: 'not-a-valid-url',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow('link_url must be a valid HTTP/HTTPS URL');
|
||||
});
|
||||
|
||||
it('应该抛出 ActionCard 缺少标题的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard content',
|
||||
INPUT_TITLE: '',
|
||||
INPUT_SINGLE_TITLE: 'Button',
|
||||
INPUT_SINGLE_URL: 'https://example.com',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow(
|
||||
'title is required for actionCard message type',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该抛出 ActionCard 缺少按钮的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard content',
|
||||
INPUT_TITLE: 'ActionCard title',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow(
|
||||
'actionCard requires either single_title/single_url or buttons',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该抛出 ActionCard 无效按钮方向的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard content',
|
||||
INPUT_TITLE: 'ActionCard title',
|
||||
INPUT_BTN_ORIENTATION: '2',
|
||||
INPUT_SINGLE_TITLE: 'Button',
|
||||
INPUT_SINGLE_URL: 'https://example.com',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow("btn_orientation must be '0' or '1'");
|
||||
});
|
||||
|
||||
it('应该抛出 ActionCard 单按钮缺少 URL 的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard content',
|
||||
INPUT_TITLE: 'ActionCard title',
|
||||
INPUT_SINGLE_TITLE: 'Button',
|
||||
INPUT_SINGLE_URL: '',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow(
|
||||
'single_url is required when using single button',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该抛出 ActionCard 无效按钮 JSON 的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard content',
|
||||
INPUT_TITLE: 'ActionCard title',
|
||||
INPUT_BUTTONS: 'invalid-json',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow(
|
||||
'actionCard requires either single_title/single_url or buttons',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该抛出 FeedCard 缺少链接的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||
INPUT_CONTENT: '',
|
||||
INPUT_FEED_LINKS: '',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow(
|
||||
'feed_links is required and must be a non-empty array for feedCard',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该抛出 FeedCard 无效链接 JSON 的错误', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||
INPUT_CONTENT: '',
|
||||
INPUT_FEED_LINKS: 'invalid-json',
|
||||
});
|
||||
|
||||
await expect(parseConfig()).rejects.toThrow(
|
||||
'feed_links is required and must be a non-empty array for feedCard',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理按钮方向中的空白字符', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard content',
|
||||
INPUT_TITLE: 'ActionCard title',
|
||||
INPUT_BTN_ORIENTATION: ' 1 ',
|
||||
INPUT_SINGLE_TITLE: 'Button',
|
||||
INPUT_SINGLE_URL: 'https://example.com',
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
expect(config.btnOrientation).toBe('1');
|
||||
});
|
||||
|
||||
it('应该过滤掉无效的手机号码', async () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'text',
|
||||
INPUT_CONTENT: 'Hello, World!',
|
||||
INPUT_AT_MOBILES: '13800138000,invalid,12345678901,15012345678,abc123',
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
expect(config.atMobiles).toEqual(['13800138000', '15012345678']);
|
||||
});
|
||||
|
||||
it('应该过滤掉无效的按钮', async () => {
|
||||
const buttons = JSON.stringify([
|
||||
{ title: '有效按钮', actionURL: 'https://example.com/valid' },
|
||||
{ title: '', actionURL: 'https://example.com/invalid' }, // 空标题
|
||||
{ title: '无效URL', actionURL: 'not-a-url' }, // 无效URL
|
||||
{ actionURL: 'https://example.com/no-title' }, // 缺少标题
|
||||
{ title: '另一个有效按钮', actionURL: 'https://example.com/valid2' },
|
||||
]);
|
||||
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||
INPUT_CONTENT: 'ActionCard content',
|
||||
INPUT_TITLE: 'ActionCard title',
|
||||
INPUT_BUTTONS: buttons,
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
expect(config.buttons).toHaveLength(2);
|
||||
expect(config.buttons[0].title).toBe('有效按钮');
|
||||
expect(config.buttons[1].title).toBe('另一个有效按钮');
|
||||
});
|
||||
|
||||
it('应该过滤掉无效的 Feed 链接', async () => {
|
||||
const feedLinks = JSON.stringify([
|
||||
{
|
||||
title: '有效链接',
|
||||
messageURL: 'https://example.com/valid',
|
||||
picURL: 'https://example.com/pic.jpg',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
messageURL: 'https://example.com/invalid',
|
||||
picURL: 'https://example.com/pic.jpg',
|
||||
}, // 空标题
|
||||
{
|
||||
title: '无效URL',
|
||||
messageURL: 'not-a-url',
|
||||
picURL: 'https://example.com/pic.jpg',
|
||||
}, // 无效URL
|
||||
{
|
||||
title: '另一个有效链接',
|
||||
messageURL: 'https://example.com/valid2',
|
||||
picURL: 'https://example.com/pic2.jpg',
|
||||
},
|
||||
]);
|
||||
|
||||
restoreEnv = mockEnvVars({
|
||||
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||
INPUT_CONTENT: 'FeedCard content',
|
||||
INPUT_FEED_LINKS: feedLinks,
|
||||
});
|
||||
|
||||
const config = await parseConfig();
|
||||
expect(config.feedLinks).toHaveLength(2);
|
||||
expect(config.feedLinks[0].title).toBe('有效链接');
|
||||
expect(config.feedLinks[1].title).toBe('另一个有效链接');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 logConfig 函数', () => {
|
||||
it('应该记录基本配置信息', () => {
|
||||
const config = {
|
||||
messageType: 'text',
|
||||
content: 'Hello, World!',
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
};
|
||||
|
||||
logConfig(config);
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('=== Action Configuration ===');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('Message Type: text');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('Content Length: 13 characters');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('Webhook URL: [HIDDEN FOR SECURITY]');
|
||||
});
|
||||
|
||||
it('应该在字段存在时记录可选字段', () => {
|
||||
const config = {
|
||||
messageType: 'markdown',
|
||||
content: 'Markdown content',
|
||||
title: 'Test Title',
|
||||
atMobiles: ['13800138000', '13900139000'],
|
||||
atAll: true,
|
||||
linkUrl: 'https://example.com',
|
||||
picUrl: 'https://example.com/pic.jpg',
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
};
|
||||
|
||||
logConfig(config);
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('Title: Test Title');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('At Mobiles: 2 numbers');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('At All: true');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('Link URL: https://example.com');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('Picture URL: https://example.com/pic.jpg');
|
||||
});
|
||||
|
||||
it('应该在字段不存在时不记录可选字段', () => {
|
||||
const config = {
|
||||
messageType: 'text',
|
||||
content: 'Simple text',
|
||||
atMobiles: [],
|
||||
atAll: false,
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||
};
|
||||
|
||||
logConfig(config);
|
||||
|
||||
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Title:'));
|
||||
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('At Mobiles:'));
|
||||
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('At All: true'));
|
||||
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Link URL:'));
|
||||
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Picture URL:'));
|
||||
});
|
||||
});
|
||||
});
|
||||
476
tests/unit/http.test.js
Normal file
476
tests/unit/http.test.js
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* http.js 单元测试
|
||||
* 测试 HTTP 客户端功能
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createRequestSummary,
|
||||
getDingTalkErrorDescription,
|
||||
sendRequest,
|
||||
validateResponse,
|
||||
} from '../../src/http.js';
|
||||
import { dingTalkResponses } from '../fixtures/responses/dingtalk-responses.js';
|
||||
|
||||
// Mock fetch API
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('测试 http.js 文件', () => {
|
||||
|
||||
/**
|
||||
* 设置 HTTP Mock 的辅助函数
|
||||
*
|
||||
* @param {object} mockResponse - 模拟的 HTTP 响应
|
||||
*/
|
||||
async function setupHttpMock(mockResponse) {
|
||||
const { statusCode = 200, data } = mockResponse;
|
||||
const responseText = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
status: statusCode,
|
||||
text: () => Promise.resolve(responseText),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置网络错误 Mock 的辅助函数
|
||||
*
|
||||
* @param {string} errorCode - 模拟的 HTTP 错误码
|
||||
*/
|
||||
function setupNetworkErrorMock(errorCode) {
|
||||
const error = new Error(`Network error: ${errorCode}`);
|
||||
error.code = errorCode;
|
||||
mockFetch.mockRejectedValueOnce(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置超时 Mock 的辅助函数
|
||||
*/
|
||||
function setupTimeoutMock() {
|
||||
const error = new Error('Request timeout');
|
||||
error.code = 'TIMEOUT';
|
||||
mockFetch.mockRejectedValueOnce(error);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock console 方法
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// 恢复所有 mocks
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// 重新设置 fetch mock
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
describe('测试 sendRequest 函数', () => {
|
||||
const testUrl = 'https://oapi.dingtalk.com/robot/send?access_token=test';
|
||||
const testMessage = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: 'Hello World',
|
||||
},
|
||||
};
|
||||
|
||||
it('应该成功发送请求', async () => {
|
||||
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.data).toEqual({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// 验证 fetch 被调用
|
||||
expect(mockFetch).toHaveBeenCalledWith(testUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': expect.stringContaining('DingTalk-Bot'),
|
||||
},
|
||||
body: JSON.stringify(testMessage),
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理钉钉 API 错误响应', async () => {
|
||||
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.error.invalidToken });
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false); // 钉钉 API 错误码非0时 success 为 false
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.data).toEqual({
|
||||
errcode: 310000,
|
||||
errmsg: 'keywords not in content',
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该处理网络连接错误', async () => {
|
||||
setupNetworkErrorMock('ECONNREFUSED');
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(0);
|
||||
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||
expect(result.error).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('应该处理超时错误', async () => {
|
||||
setupTimeoutMock();
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(0);
|
||||
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||
expect(result.error).toContain('timeout');
|
||||
});
|
||||
|
||||
it('应该处理 DNS 解析错误', async () => {
|
||||
setupNetworkErrorMock('ENOTFOUND');
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(0);
|
||||
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||
expect(result.error).toContain('ENOTFOUND');
|
||||
});
|
||||
|
||||
it('应该处理 SSL 错误', async () => {
|
||||
setupNetworkErrorMock('CERT_HAS_EXPIRED');
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(0);
|
||||
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||
expect(result.error).toContain('CERT_HAS_EXPIRED');
|
||||
});
|
||||
|
||||
it('应该处理非 JSON 响应', async () => {
|
||||
await setupHttpMock({
|
||||
statusCode: 200,
|
||||
data: 'Not JSON response',
|
||||
});
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.data).toEqual({});
|
||||
expect(result.error).toContain('JSON parse error');
|
||||
});
|
||||
|
||||
it('应该处理空响应', async () => {
|
||||
await setupHttpMock({
|
||||
statusCode: 200,
|
||||
data: '',
|
||||
});
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.data).toEqual({});
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该正确设置请求头', async () => {
|
||||
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
|
||||
|
||||
await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(testUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': expect.stringContaining('DingTalk-Bot'),
|
||||
},
|
||||
body: JSON.stringify(testMessage),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 validateResponse 函数', () => {
|
||||
it('应该验证有效的成功响应', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
message_id: 'msg123456',
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const result = validateResponse(response);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该验证有效的错误响应', () => {
|
||||
const response = {
|
||||
success: false,
|
||||
statusCode: 400,
|
||||
data: {
|
||||
errcode: 310000,
|
||||
errmsg: 'invalid token',
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const result = validateResponse(response);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该检测缺少必需字段的响应', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
errcode: 0,
|
||||
// 缺少 errmsg
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const result = validateResponse(response);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: errmsg');
|
||||
});
|
||||
|
||||
it('应该检测字段类型错误', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
statusCode: '200', // 应该是数字
|
||||
data: {
|
||||
errcode: '0', // 应该是数字
|
||||
errmsg: 'ok',
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const result = validateResponse(response);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('statusCode must be a number');
|
||||
expect(result.errors).toContain('errcode must be a number');
|
||||
});
|
||||
|
||||
it('应该检测无效的响应结构', () => {
|
||||
const response = null;
|
||||
|
||||
const result = validateResponse(response);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Response is null or undefined');
|
||||
});
|
||||
|
||||
it('应该检测缺少 data 字段', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
// 缺少 data
|
||||
};
|
||||
|
||||
const result = validateResponse(response);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 getDingTalkErrorDescription 函数', () => {
|
||||
it('应该返回已知错误码的描述', () => {
|
||||
expect(getDingTalkErrorDescription(310000)).toBe('无效的 webhook URL 或访问令牌');
|
||||
expect(getDingTalkErrorDescription(310001)).toBe('无效签名');
|
||||
expect(getDingTalkErrorDescription(310002)).toBe('无效时间戳');
|
||||
expect(getDingTalkErrorDescription(310003)).toBe('无效请求格式');
|
||||
expect(getDingTalkErrorDescription(310004)).toBe('消息内容过长');
|
||||
expect(getDingTalkErrorDescription(310005)).toBe('消息发送频率超限');
|
||||
expect(getDingTalkErrorDescription(-1)).toBe('系统繁忙,请稍后再试');
|
||||
});
|
||||
|
||||
it('应该返回未知错误码的默认描述', () => {
|
||||
expect(getDingTalkErrorDescription(999999)).toBe('未知错误');
|
||||
expect(getDingTalkErrorDescription(0)).toBe('请求成功');
|
||||
});
|
||||
|
||||
it('应该处理非数字错误码', () => {
|
||||
expect(getDingTalkErrorDescription('invalid')).toBe('未知错误');
|
||||
expect(getDingTalkErrorDescription(null)).toBe('未知错误');
|
||||
expect(getDingTalkErrorDescription(undefined)).toBe('未知错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 createRequestSummary 函数', () => {
|
||||
it('应该为成功响应创建摘要', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
message_id: 'msg123456',
|
||||
},
|
||||
error: null,
|
||||
duration: 150,
|
||||
};
|
||||
|
||||
const summary = createRequestSummary(response);
|
||||
|
||||
expect(summary).toContain('Status: 200');
|
||||
expect(summary).toContain('Duration: 150ms');
|
||||
expect(summary).toContain('DingTalk Code: 0');
|
||||
});
|
||||
|
||||
it('应该为失败响应创建摘要', () => {
|
||||
const response = {
|
||||
success: false,
|
||||
statusCode: 400,
|
||||
data: {
|
||||
errcode: 310000,
|
||||
errmsg: 'invalid token',
|
||||
},
|
||||
error: null,
|
||||
duration: 200,
|
||||
};
|
||||
|
||||
const summary = createRequestSummary(response);
|
||||
|
||||
expect(summary).toContain('Status: 400');
|
||||
expect(summary).toContain('Duration: 200ms');
|
||||
expect(summary).toContain('DingTalk Code: 310000');
|
||||
expect(summary).toContain('无效的 webhook URL 或访问令牌');
|
||||
});
|
||||
|
||||
it('应该为网络错误创建摘要', () => {
|
||||
const response = {
|
||||
success: false,
|
||||
statusCode: 0,
|
||||
data: {},
|
||||
error: 'ECONNREFUSED',
|
||||
duration: 5000,
|
||||
};
|
||||
|
||||
const summary = createRequestSummary(response);
|
||||
|
||||
expect(summary).toContain('Status: 0');
|
||||
expect(summary).toContain('Duration: 5000ms');
|
||||
expect(summary).toContain('Error: ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('应该处理缺少消息ID的成功响应', () => {
|
||||
const response = {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
// 缺少 message_id
|
||||
},
|
||||
error: null,
|
||||
duration: 100,
|
||||
};
|
||||
|
||||
const summary = createRequestSummary(response);
|
||||
|
||||
expect(summary).toContain('Status: 200');
|
||||
expect(summary).toContain('Duration: 100ms');
|
||||
expect(summary).toContain('DingTalk Code: 0');
|
||||
expect(summary).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('应该处理空的错误消息', () => {
|
||||
const response = {
|
||||
success: false,
|
||||
statusCode: 400,
|
||||
data: {
|
||||
errcode: 300001,
|
||||
errmsg: '',
|
||||
},
|
||||
error: null,
|
||||
duration: 250,
|
||||
};
|
||||
|
||||
const summary = createRequestSummary(response);
|
||||
|
||||
expect(summary).toContain('Status: 400');
|
||||
expect(summary).toContain('Duration: 250ms');
|
||||
expect(summary).toContain('DingTalk Code: 300001');
|
||||
expect(summary).toContain('未知错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界条件测试', () => {
|
||||
const testUrl = 'https://oapi.dingtalk.com/robot/send?access_token=test';
|
||||
const testMessage = { msgtype: 'text', text: { content: 'test' } };
|
||||
|
||||
it('应该处理极大的响应数据', async () => {
|
||||
const largeData = {
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
message_id: 'msg123456',
|
||||
large_field: 'x'.repeat(10000), // 10KB 的数据
|
||||
};
|
||||
|
||||
await setupHttpMock({
|
||||
statusCode: 200,
|
||||
data: largeData,
|
||||
});
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.large_field).toBe('x'.repeat(10000));
|
||||
});
|
||||
|
||||
it('应该处理特殊字符的响应', async () => {
|
||||
const specialData = {
|
||||
errcode: 0,
|
||||
errmsg: 'ok with 特殊字符 and émojis 🎉',
|
||||
message_id: 'msg123456',
|
||||
};
|
||||
|
||||
await setupHttpMock({
|
||||
statusCode: 200,
|
||||
data: specialData,
|
||||
});
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.errmsg).toBe('ok with 特殊字符 and émojis 🎉');
|
||||
});
|
||||
|
||||
it('应该处理畸形的 JSON 响应', async () => {
|
||||
await setupHttpMock(dingTalkResponses.special.malformedJson);
|
||||
|
||||
const result = await sendRequest(testUrl, testMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(typeof result.error).toBe('string');
|
||||
expect(result.error).toContain('JSON parse error');
|
||||
});
|
||||
});
|
||||
});
|
||||
939
tests/unit/message.test.js
Normal file
939
tests/unit/message.test.js
Normal file
@@ -0,0 +1,939 @@
|
||||
/**
|
||||
* message.js 文件单元测试
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MESSAGE_TYPES } from '../../src/config.js';
|
||||
import {
|
||||
buildActionCardMessage,
|
||||
buildFeedCardMessage,
|
||||
buildLinkMessage,
|
||||
buildMarkdownMessage,
|
||||
buildMessage,
|
||||
buildTextMessage,
|
||||
getMessageSummary,
|
||||
validateMessage,
|
||||
} from '../../src/message.js';
|
||||
import { assertDingTalkMessage } from '../helpers/assertions.js';
|
||||
|
||||
describe('测试 message.js 文件', () => {
|
||||
describe('测试 buildTextMessage 函数', () => {
|
||||
it('应该构建基本的文本消息', () => {
|
||||
const message = buildTextMessage('Hello, World!');
|
||||
|
||||
assertDingTalkMessage(message, MESSAGE_TYPES.TEXT);
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||
expect(message.text.content).toBe('Hello, World!');
|
||||
expect(message.at).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该构建包含@手机号的文本消息', () => {
|
||||
const atMobiles = ['13800138000', '13900139000'];
|
||||
const message = buildTextMessage('Hello, World!', atMobiles);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||
expect(message.text.content).toBe('Hello, World!');
|
||||
expect(message.at).toEqual({
|
||||
atMobiles: ['13800138000', '13900139000'],
|
||||
isAtAll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该构建@所有人的文本消息', () => {
|
||||
const message = buildTextMessage('Hello, World!', [], true);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||
expect(message.text.content).toBe('Hello, World!');
|
||||
expect(message.at).toEqual({
|
||||
atMobiles: [],
|
||||
isAtAll: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该构建同时包含@手机号和@所有人的文本消息', () => {
|
||||
const atMobiles = ['13800138000'];
|
||||
const message = buildTextMessage('Hello, World!', atMobiles, true);
|
||||
|
||||
expect(message.at).toEqual({
|
||||
atMobiles: ['13800138000'],
|
||||
isAtAll: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该对缺少内容抛出错误', () => {
|
||||
expect(() => buildTextMessage()).toThrow('Content is required for text message');
|
||||
expect(() => buildTextMessage('')).toThrow('Content is required for text message');
|
||||
expect(() => buildTextMessage(null)).toThrow('Content is required for text message');
|
||||
expect(() => buildTextMessage(123)).toThrow('Content is required for text message');
|
||||
});
|
||||
|
||||
it('应该处理空的@手机号数组', () => {
|
||||
const message = buildTextMessage('Hello, World!', []);
|
||||
|
||||
expect(message.at).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该处理 null/undefined 的@手机号', () => {
|
||||
const message1 = buildTextMessage('Hello, World!', null);
|
||||
const message2 = buildTextMessage('Hello, World!', undefined);
|
||||
|
||||
expect(message1.at).toBeUndefined();
|
||||
expect(message2.at).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 buildMarkdownMessage 函数', () => {
|
||||
it('应该构建基本的 Markdown 消息', () => {
|
||||
const message = buildMarkdownMessage('标题', '## Markdown 内容');
|
||||
|
||||
assertDingTalkMessage(message, MESSAGE_TYPES.MARKDOWN);
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||
expect(message.markdown.title).toBe('标题');
|
||||
expect(message.markdown.text).toBe('## Markdown 内容');
|
||||
expect(message.at).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该构建包含@手机号的 Markdown 消息', () => {
|
||||
const atMobiles = ['13800138000', '13900139000'];
|
||||
const message = buildMarkdownMessage('标题', '## Markdown 内容', atMobiles);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||
expect(message.markdown.title).toBe('标题');
|
||||
expect(message.markdown.text).toBe('## Markdown 内容\n\n@13800138000 @13900139000');
|
||||
expect(message.at).toEqual({
|
||||
atMobiles: ['13800138000', '13900139000'],
|
||||
isAtAll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该构建@所有人的 Markdown 消息', () => {
|
||||
const message = buildMarkdownMessage('标题', '## Markdown 内容', [], true);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||
expect(message.markdown.text).toBe('## Markdown 内容\n\n@所有人');
|
||||
expect(message.at).toEqual({
|
||||
atMobiles: [],
|
||||
isAtAll: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该构建同时包含@手机号和@所有人的 Markdown 消息', () => {
|
||||
const atMobiles = ['13800138000'];
|
||||
const message = buildMarkdownMessage('标题', '## Markdown 内容', atMobiles, true);
|
||||
|
||||
expect(message.markdown.text).toBe('## Markdown 内容\n\n@13800138000\n\n@所有人');
|
||||
expect(message.at).toEqual({
|
||||
atMobiles: ['13800138000'],
|
||||
isAtAll: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该对缺少标题抛出错误', () => {
|
||||
expect(() => buildMarkdownMessage()).toThrow('Title is required for markdown message');
|
||||
expect(() => buildMarkdownMessage('')).toThrow('Title is required for markdown message');
|
||||
expect(() => buildMarkdownMessage(null)).toThrow('Title is required for markdown message');
|
||||
expect(() => buildMarkdownMessage(123)).toThrow('Title is required for markdown message');
|
||||
});
|
||||
|
||||
it('应该对缺少内容抛出错误', () => {
|
||||
expect(() => buildMarkdownMessage('标题')).toThrow(
|
||||
'Content is required for markdown message',
|
||||
);
|
||||
expect(() => buildMarkdownMessage('标题', '')).toThrow(
|
||||
'Content is required for markdown message',
|
||||
);
|
||||
expect(() => buildMarkdownMessage('标题', null)).toThrow(
|
||||
'Content is required for markdown message',
|
||||
);
|
||||
expect(() => buildMarkdownMessage('标题', 123)).toThrow(
|
||||
'Content is required for markdown message',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 buildLinkMessage 函数', () => {
|
||||
it('应该构建基本的链接消息', () => {
|
||||
const message = buildLinkMessage('链接标题', '链接描述', 'https://example.com');
|
||||
|
||||
assertDingTalkMessage(message, MESSAGE_TYPES.LINK);
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.LINK);
|
||||
expect(message.link.title).toBe('链接标题');
|
||||
expect(message.link.text).toBe('链接描述');
|
||||
expect(message.link.messageUrl).toBe('https://example.com');
|
||||
expect(message.link.picUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该构建包含图片 URL 的链接消息', () => {
|
||||
const message = buildLinkMessage(
|
||||
'链接标题',
|
||||
'链接描述',
|
||||
'https://example.com',
|
||||
'https://example.com/image.jpg',
|
||||
);
|
||||
|
||||
expect(message.link.picUrl).toBe('https://example.com/image.jpg');
|
||||
});
|
||||
|
||||
it('应该忽略无效的图片 URL', () => {
|
||||
const message = buildLinkMessage(
|
||||
'链接标题',
|
||||
'链接描述',
|
||||
'https://example.com',
|
||||
'not-a-valid-url',
|
||||
);
|
||||
|
||||
expect(message.link.picUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该忽略非字符串的图片 URL', () => {
|
||||
const message = buildLinkMessage('链接标题', '链接描述', 'https://example.com', 123);
|
||||
|
||||
expect(message.link.picUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该对缺少标题抛出错误', () => {
|
||||
expect(() => buildLinkMessage()).toThrow('Title is required for link message');
|
||||
expect(() => buildLinkMessage('')).toThrow('Title is required for link message');
|
||||
expect(() => buildLinkMessage(null)).toThrow('Title is required for link message');
|
||||
expect(() => buildLinkMessage(123)).toThrow('Title is required for link message');
|
||||
});
|
||||
|
||||
it('应该对缺少内容抛出错误', () => {
|
||||
expect(() => buildLinkMessage('标题')).toThrow('Content is required for link message');
|
||||
expect(() => buildLinkMessage('标题', '')).toThrow('Content is required for link message');
|
||||
expect(() => buildLinkMessage('标题', null)).toThrow('Content is required for link message');
|
||||
expect(() => buildLinkMessage('标题', 123)).toThrow('Content is required for link message');
|
||||
});
|
||||
|
||||
it('应该对缺少链接 URL 抛出错误', () => {
|
||||
expect(() => buildLinkMessage('标题', '内容')).toThrow(
|
||||
'Link URL is required for link message',
|
||||
);
|
||||
expect(() => buildLinkMessage('标题', '内容', '')).toThrow(
|
||||
'Link URL is required for link message',
|
||||
);
|
||||
expect(() => buildLinkMessage('标题', '内容', null)).toThrow(
|
||||
'Link URL is required for link message',
|
||||
);
|
||||
expect(() => buildLinkMessage('标题', '内容', 123)).toThrow(
|
||||
'Link URL is required for link message',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该对无效的链接 URL 格式抛出错误', () => {
|
||||
expect(() => buildLinkMessage('标题', '内容', 'not-a-url')).toThrow(
|
||||
'Link URL must be a valid HTTP/HTTPS URL',
|
||||
);
|
||||
expect(() => buildLinkMessage('标题', '内容', 'ftp://example.com')).toThrow(
|
||||
'Link URL must be a valid HTTP/HTTPS URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该接受 HTTP 和 HTTPS URL', () => {
|
||||
const httpMessage = buildLinkMessage('标题', '内容', 'http://example.com');
|
||||
const httpsMessage = buildLinkMessage('标题', '内容', 'https://example.com');
|
||||
|
||||
expect(httpMessage.link.messageUrl).toBe('http://example.com');
|
||||
expect(httpsMessage.link.messageUrl).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 buildActionCardMessage 函数', () => {
|
||||
it('应该构建单按钮 ActionCard 消息', () => {
|
||||
const options = {
|
||||
btnOrientation: '0',
|
||||
singleTitle: '查看详情',
|
||||
singleURL: 'https://example.com/detail',
|
||||
};
|
||||
|
||||
const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options);
|
||||
|
||||
assertDingTalkMessage(message, MESSAGE_TYPES.ACTION_CARD);
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD);
|
||||
expect(message.actionCard.title).toBe('ActionCard 标题');
|
||||
expect(message.actionCard.text).toBe('ActionCard 内容');
|
||||
expect(message.actionCard.btnOrientation).toBe('0');
|
||||
expect(message.actionCard.singleTitle).toBe('查看详情');
|
||||
expect(message.actionCard.singleURL).toBe('https://example.com/detail');
|
||||
expect(message.actionCard.btns).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该构建多按钮 ActionCard 消息', () => {
|
||||
const options = {
|
||||
btnOrientation: '1',
|
||||
buttons: [
|
||||
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||
],
|
||||
};
|
||||
|
||||
const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD);
|
||||
expect(message.actionCard.btnOrientation).toBe('1');
|
||||
expect(message.actionCard.btns).toEqual([
|
||||
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||
]);
|
||||
expect(message.actionCard.singleTitle).toBeUndefined();
|
||||
expect(message.actionCard.singleURL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该使用默认的按钮方向', () => {
|
||||
const options = {
|
||||
singleTitle: '查看详情',
|
||||
singleURL: 'https://example.com/detail',
|
||||
};
|
||||
|
||||
const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options);
|
||||
|
||||
expect(message.actionCard.btnOrientation).toBe('0');
|
||||
});
|
||||
|
||||
it('应该处理空选项', () => {
|
||||
expect(() => buildActionCardMessage('标题', '内容', {})).toThrow(
|
||||
'actionCard requires either singleTitle/singleURL or buttons',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该处理未定义的选项', () => {
|
||||
expect(() => buildActionCardMessage('标题', '内容')).toThrow(
|
||||
'actionCard requires either singleTitle/singleURL or buttons',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该对缺少标题抛出错误', () => {
|
||||
expect(() => buildActionCardMessage()).toThrow('Title is required for actionCard message');
|
||||
expect(() => buildActionCardMessage('')).toThrow('Title is required for actionCard message');
|
||||
expect(() => buildActionCardMessage(null)).toThrow(
|
||||
'Title is required for actionCard message',
|
||||
);
|
||||
expect(() => buildActionCardMessage(123)).toThrow('Title is required for actionCard message');
|
||||
});
|
||||
|
||||
it('应该对缺少内容抛出错误', () => {
|
||||
expect(() => buildActionCardMessage('标题')).toThrow(
|
||||
'Content is required for actionCard message',
|
||||
);
|
||||
expect(() => buildActionCardMessage('标题', '')).toThrow(
|
||||
'Content is required for actionCard message',
|
||||
);
|
||||
expect(() => buildActionCardMessage('标题', null)).toThrow(
|
||||
'Content is required for actionCard message',
|
||||
);
|
||||
expect(() => buildActionCardMessage('标题', 123)).toThrow(
|
||||
'Content is required for actionCard message',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该对既没有单按钮也没有按钮数组的情况抛出错误', () => {
|
||||
expect(() => buildActionCardMessage('标题', '内容', {})).toThrow(
|
||||
'actionCard requires either singleTitle/singleURL or buttons',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该处理空的按钮数组', () => {
|
||||
const options = { buttons: [] };
|
||||
|
||||
expect(() => buildActionCardMessage('标题', '内容', options)).toThrow(
|
||||
'actionCard requires either singleTitle/singleURL or buttons',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该处理非数组的按钮', () => {
|
||||
const options = { buttons: 'not-an-array' };
|
||||
|
||||
expect(() => buildActionCardMessage('标题', '内容', options)).toThrow(
|
||||
'actionCard requires either singleTitle/singleURL or buttons',
|
||||
);
|
||||
});
|
||||
|
||||
it('当同时提供单按钮和按钮数组时应该优先使用单按钮', () => {
|
||||
const options = {
|
||||
singleTitle: '单按钮',
|
||||
singleURL: 'https://example.com/single',
|
||||
buttons: [{ title: '多按钮', actionURL: 'https://example.com/multi' }],
|
||||
};
|
||||
|
||||
const message = buildActionCardMessage('标题', '内容', options);
|
||||
|
||||
expect(message.actionCard.singleTitle).toBe('单按钮');
|
||||
expect(message.actionCard.singleURL).toBe('https://example.com/single');
|
||||
expect(message.actionCard.btns).toEqual([
|
||||
{ title: '多按钮', actionURL: 'https://example.com/multi' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 buildFeedCardMessage 函数', () => {
|
||||
it('应该构建 FeedCard 消息', () => {
|
||||
const links = [
|
||||
{
|
||||
title: '新闻1',
|
||||
messageURL: 'https://example.com/news1',
|
||||
picURL: 'https://example.com/pic1.jpg',
|
||||
},
|
||||
{
|
||||
title: '新闻2',
|
||||
messageURL: 'https://example.com/news2',
|
||||
picURL: 'https://example.com/pic2.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const message = buildFeedCardMessage(links);
|
||||
|
||||
assertDingTalkMessage(message, MESSAGE_TYPES.FEED_CARD);
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.FEED_CARD);
|
||||
expect(message.feedCard.links).toEqual(links);
|
||||
});
|
||||
|
||||
it('应该构建包含单个链接的 FeedCard 消息', () => {
|
||||
const links = [
|
||||
{
|
||||
title: '单个新闻',
|
||||
messageURL: 'https://example.com/news',
|
||||
picURL: 'https://example.com/pic.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const message = buildFeedCardMessage(links);
|
||||
|
||||
expect(message.feedCard.links).toHaveLength(1);
|
||||
expect(message.feedCard.links[0]).toEqual(links[0]);
|
||||
});
|
||||
|
||||
it('应该对缺少链接抛出错误', () => {
|
||||
expect(() => buildFeedCardMessage()).toThrow('FeedCard requires a non-empty links array');
|
||||
expect(() => buildFeedCardMessage(null)).toThrow('FeedCard requires a non-empty links array');
|
||||
expect(() => buildFeedCardMessage('not-an-array')).toThrow(
|
||||
'FeedCard requires a non-empty links array',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该对空链接数组抛出错误', () => {
|
||||
expect(() => buildFeedCardMessage([])).toThrow('FeedCard requires a non-empty links array');
|
||||
});
|
||||
|
||||
it('应该正确映射链接属性', () => {
|
||||
const links = [
|
||||
{
|
||||
title: '测试标题',
|
||||
messageURL: 'https://test.com',
|
||||
picURL: 'https://test.com/pic.jpg',
|
||||
extraProperty: 'should be ignored',
|
||||
},
|
||||
];
|
||||
|
||||
const message = buildFeedCardMessage(links);
|
||||
|
||||
expect(message.feedCard.links[0]).toEqual({
|
||||
title: '测试标题',
|
||||
messageURL: 'https://test.com',
|
||||
picURL: 'https://test.com/pic.jpg',
|
||||
});
|
||||
expect(message.feedCard.links[0].extraProperty).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 buildMessage 函数', () => {
|
||||
it('应该根据配置构建文本消息', () => {
|
||||
const config = {
|
||||
messageType: 'text',
|
||||
content: 'Hello, World!',
|
||||
atMobiles: ['13800138000'],
|
||||
atAll: false,
|
||||
};
|
||||
|
||||
const message = buildMessage(config);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||
expect(message.text.content).toBe('Hello, World!');
|
||||
expect(message.at.atMobiles).toEqual(['13800138000']);
|
||||
});
|
||||
|
||||
it('应该根据配置构建 Markdown 消息', () => {
|
||||
const config = {
|
||||
messageType: 'markdown',
|
||||
title: 'Markdown 标题',
|
||||
content: '## Markdown 内容',
|
||||
atMobiles: [],
|
||||
atAll: true,
|
||||
};
|
||||
|
||||
const message = buildMessage(config);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||
expect(message.markdown.title).toBe('Markdown 标题');
|
||||
expect(message.at.isAtAll).toBe(true);
|
||||
});
|
||||
|
||||
it('应该根据配置构建链接消息', () => {
|
||||
const config = {
|
||||
messageType: 'link',
|
||||
title: '链接标题',
|
||||
content: '链接描述',
|
||||
linkUrl: 'https://example.com',
|
||||
picUrl: 'https://example.com/pic.jpg',
|
||||
};
|
||||
|
||||
const message = buildMessage(config);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.LINK);
|
||||
expect(message.link.title).toBe('链接标题');
|
||||
expect(message.link.messageUrl).toBe('https://example.com');
|
||||
expect(message.link.picUrl).toBe('https://example.com/pic.jpg');
|
||||
});
|
||||
|
||||
it('应该根据配置构建 ActionCard 消息', () => {
|
||||
const config = {
|
||||
messageType: 'actionCard',
|
||||
title: 'ActionCard 标题',
|
||||
content: 'ActionCard 内容',
|
||||
btnOrientation: '1',
|
||||
buttons: [{ title: '按钮1', actionURL: 'https://example.com/action1' }],
|
||||
};
|
||||
|
||||
const message = buildMessage(config);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD);
|
||||
expect(message.actionCard.title).toBe('ActionCard 标题');
|
||||
expect(message.actionCard.btnOrientation).toBe('1');
|
||||
expect(message.actionCard.btns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('应该根据配置构建 FeedCard 消息', () => {
|
||||
const config = {
|
||||
messageType: 'feedCard',
|
||||
feedLinks: [
|
||||
{
|
||||
title: '新闻1',
|
||||
messageURL: 'https://example.com/news1',
|
||||
picURL: 'https://example.com/pic1.jpg',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const message = buildMessage(config);
|
||||
|
||||
expect(message.msgtype).toBe(MESSAGE_TYPES.FEED_CARD);
|
||||
expect(message.feedCard.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('应该对不支持的消息类型抛出错误', () => {
|
||||
const config = {
|
||||
messageType: 'unsupported',
|
||||
content: 'Test content',
|
||||
};
|
||||
|
||||
expect(() => buildMessage(config)).toThrow('Unsupported message type: unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 validateMessage 函数', () => {
|
||||
it('应该验证有效的文本消息', () => {
|
||||
const message = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: 'Hello, World!',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该验证有效的 Markdown 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: '标题',
|
||||
text: '## 内容',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该验证有效的链接消息', () => {
|
||||
const message = {
|
||||
msgtype: 'link',
|
||||
link: {
|
||||
title: '链接标题',
|
||||
text: '链接描述',
|
||||
messageUrl: 'https://example.com',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该验证有效的单按钮 ActionCard 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'actionCard',
|
||||
actionCard: {
|
||||
title: 'ActionCard 标题',
|
||||
text: 'ActionCard 内容',
|
||||
singleTitle: '查看详情',
|
||||
singleURL: 'https://example.com',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该验证有效的多按钮 ActionCard 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'actionCard',
|
||||
actionCard: {
|
||||
title: 'ActionCard 标题',
|
||||
text: 'ActionCard 内容',
|
||||
btns: [
|
||||
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该验证有效的 FeedCard 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'feedCard',
|
||||
feedCard: {
|
||||
links: [
|
||||
{
|
||||
title: '新闻1',
|
||||
messageURL: 'https://example.com/news1',
|
||||
picURL: 'https://example.com/pic1.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该拒绝 null/undefined 消息', () => {
|
||||
expect(validateMessage(null).isValid).toBe(false);
|
||||
expect(validateMessage(undefined).isValid).toBe(false);
|
||||
expect(validateMessage('not-an-object').isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝没有 msgtype 的消息', () => {
|
||||
const message = {
|
||||
text: { content: 'Hello' },
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid or missing msgtype');
|
||||
});
|
||||
|
||||
it('应该拒绝带有无效 msgtype 的消息', () => {
|
||||
const message = {
|
||||
msgtype: 'invalid',
|
||||
text: { content: 'Hello' },
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid or missing msgtype');
|
||||
});
|
||||
|
||||
it('应该拒绝没有内容的文本消息', () => {
|
||||
const message = {
|
||||
msgtype: 'text',
|
||||
text: {},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Text message must have content');
|
||||
});
|
||||
|
||||
it('应该拒绝没有标题或内容的 Markdown 消息', () => {
|
||||
const message1 = {
|
||||
msgtype: 'markdown',
|
||||
markdown: { text: '内容' },
|
||||
};
|
||||
|
||||
const message2 = {
|
||||
msgtype: 'markdown',
|
||||
markdown: { title: '标题' },
|
||||
};
|
||||
|
||||
expect(validateMessage(message1).isValid).toBe(false);
|
||||
expect(validateMessage(message2).isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝缺少字段的链接消息', () => {
|
||||
const message = {
|
||||
msgtype: 'link',
|
||||
link: { title: '标题' },
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Link message must have title, text, and messageUrl');
|
||||
});
|
||||
|
||||
it('应该拒绝单按钮不完整的 ActionCard 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'actionCard',
|
||||
actionCard: {
|
||||
title: '标题',
|
||||
text: '内容',
|
||||
singleTitle: '按钮',
|
||||
// missing singleURL
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'ActionCard single button requires both singleTitle and singleURL',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该拒绝带有无效按钮的 ActionCard 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'actionCard',
|
||||
actionCard: {
|
||||
title: '标题',
|
||||
text: '内容',
|
||||
btns: [
|
||||
{ title: '按钮1' }, // missing actionURL
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Each ActionCard button must have title and actionURL');
|
||||
});
|
||||
|
||||
it('应该拒绝没有链接的 FeedCard 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'feedCard',
|
||||
feedCard: {},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('FeedCard must have a non-empty links array');
|
||||
});
|
||||
|
||||
it('应该拒绝带有无效链接的 FeedCard 消息', () => {
|
||||
const message = {
|
||||
msgtype: 'feedCard',
|
||||
feedCard: {
|
||||
links: [
|
||||
{ title: '新闻1' }, // missing messageURL and picURL
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateMessage(message);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Each FeedCard link must have title, messageURL and picURL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 getMessageSummary 函数', () => {
|
||||
it('应该返回文本消息的摘要', () => {
|
||||
const message = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: 'Hello, World!',
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: text, Content: "Hello, World!"');
|
||||
});
|
||||
|
||||
it('应该截断长文本内容', () => {
|
||||
const longContent = 'A'.repeat(60);
|
||||
const message = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: longContent,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toContain(`Type: text, Content: "${'A'.repeat(50)}..."`);
|
||||
});
|
||||
|
||||
it('应该返回 Markdown 消息的摘要', () => {
|
||||
const message = {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: 'Markdown 标题',
|
||||
text: '## Markdown 内容',
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: markdown, Title: "Markdown 标题", Text: "## Markdown 内容"');
|
||||
});
|
||||
|
||||
it('应该返回链接消息的摘要', () => {
|
||||
const message = {
|
||||
msgtype: 'link',
|
||||
link: {
|
||||
title: '链接标题',
|
||||
text: '链接描述',
|
||||
messageUrl: 'https://example.com',
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: link, Title: "链接标题", URL: "https://example.com"');
|
||||
});
|
||||
|
||||
it('应该返回单按钮 ActionCard 消息的摘要', () => {
|
||||
const message = {
|
||||
msgtype: 'actionCard',
|
||||
actionCard: {
|
||||
title: 'ActionCard 标题',
|
||||
text: 'ActionCard 内容',
|
||||
singleTitle: '查看详情',
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: actionCard, Title: "ActionCard 标题", Single: "查看详情"');
|
||||
});
|
||||
|
||||
it('应该返回多按钮 ActionCard 消息的摘要', () => {
|
||||
const message = {
|
||||
msgtype: 'actionCard',
|
||||
actionCard: {
|
||||
title: 'ActionCard 标题',
|
||||
text: 'ActionCard 内容',
|
||||
btns: [
|
||||
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: actionCard, Title: "ActionCard 标题", Buttons: 2');
|
||||
});
|
||||
|
||||
it('应该返回 FeedCard 消息的摘要', () => {
|
||||
const message = {
|
||||
msgtype: 'feedCard',
|
||||
feedCard: {
|
||||
links: [
|
||||
{
|
||||
title: '新闻1',
|
||||
messageURL: 'https://example.com/news1',
|
||||
picURL: 'https://example.com/pic1.jpg',
|
||||
},
|
||||
{
|
||||
title: '新闻2',
|
||||
messageURL: 'https://example.com/news2',
|
||||
picURL: 'https://example.com/pic2.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: feedCard, Links: 2');
|
||||
});
|
||||
|
||||
it('应该包含 @所有人 信息', () => {
|
||||
const message = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: 'Hello, World!',
|
||||
},
|
||||
at: {
|
||||
isAtAll: true,
|
||||
atMobiles: [],
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: text, Content: "Hello, World!", @All: true');
|
||||
});
|
||||
|
||||
it('应该包含 @手机号 信息', () => {
|
||||
const message = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: 'Hello, World!',
|
||||
},
|
||||
at: {
|
||||
isAtAll: false,
|
||||
atMobiles: ['13800138000', '13900139000'],
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: text, Content: "Hello, World!", @Mobiles: 2');
|
||||
});
|
||||
|
||||
it('应该同时包含 @所有人 和 @手机号 信息', () => {
|
||||
const message = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: 'Hello, World!',
|
||||
},
|
||||
at: {
|
||||
isAtAll: true,
|
||||
atMobiles: ['13800138000'],
|
||||
},
|
||||
};
|
||||
|
||||
const summary = getMessageSummary(message);
|
||||
|
||||
expect(summary).toBe('Type: text, Content: "Hello, World!", @All: true, @Mobiles: 1');
|
||||
});
|
||||
|
||||
it('应该处理无效的消息', () => {
|
||||
expect(getMessageSummary(null)).toBe('Invalid message');
|
||||
expect(getMessageSummary({})).toBe('Invalid message');
|
||||
expect(getMessageSummary({ msgtype: null })).toBe('Invalid message');
|
||||
});
|
||||
});
|
||||
});
|
||||
573
tests/unit/utils.test.js
Normal file
573
tests/unit/utils.test.js
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* utils.js 文件单元测试
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as utils from '../../src/utils.js';
|
||||
import { assertError } from '../helpers/assertions.js';
|
||||
import { mockEnvVars } from '../helpers/env-mock.js';
|
||||
|
||||
describe('测试 utils.js 文件', () => {
|
||||
let restoreEnv;
|
||||
let consoleSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock console methods
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore console methods
|
||||
Object.values(consoleSpy).forEach(spy => spy.mockRestore());
|
||||
|
||||
// Restore environment variables
|
||||
if (restoreEnv) {
|
||||
restoreEnv();
|
||||
restoreEnv = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('测试 LOG_LEVELS 常量', () => {
|
||||
it('应该导出正确的日志级别', () => {
|
||||
expect(utils.LOG_LEVELS).toEqual({
|
||||
DEBUG: 'DEBUG',
|
||||
INFO: 'INFO',
|
||||
WARN: 'WARN',
|
||||
ERROR: 'ERROR',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 getTimestamp 函数', () => {
|
||||
it('应该返回 ISO 字符串时间戳', () => {
|
||||
const timestamp = utils.getTimestamp();
|
||||
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(() => new Date(timestamp)).not.toThrow();
|
||||
});
|
||||
|
||||
it('应该为连续调用返回不同的时间戳', async () => {
|
||||
const timestamp1 = utils.getTimestamp();
|
||||
// Add a small delay to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const timestamp2 = utils.getTimestamp();
|
||||
expect(timestamp1).not.toBe(timestamp2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 formatLogMessage 函数', () => {
|
||||
it('应该使用级别和消息格式化日志消息', () => {
|
||||
const message = utils.formatLogMessage('INFO', 'Test message');
|
||||
expect(message).toMatch(
|
||||
/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] Test message$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在提供元数据时包含它', () => {
|
||||
const meta = { key: 'value', number: 123 };
|
||||
const message = utils.formatLogMessage('DEBUG', 'Test message', meta);
|
||||
expect(message).toContain('Test message | {"key":"value","number":123}');
|
||||
});
|
||||
|
||||
it('应该处理 null 元数据', () => {
|
||||
const message = utils.formatLogMessage('WARN', 'Test message', null);
|
||||
expect(message).not.toContain('|');
|
||||
expect(message).toContain('Test message');
|
||||
});
|
||||
|
||||
it('应该处理 undefined 元数据', () => {
|
||||
const message = utils.formatLogMessage('ERROR', 'Test message');
|
||||
expect(message).not.toContain('|');
|
||||
expect(message).toContain('Test message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 logDebug 函数', () => {
|
||||
it('应该在 DEBUG=true 时记录调试消息', () => {
|
||||
restoreEnv = mockEnvVars({ DEBUG: 'true' });
|
||||
|
||||
utils.logDebug('Debug message', { test: true });
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEBUG] Debug message | {"test":true}'),
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在 NODE_ENV=development 时记录调试消息', () => {
|
||||
restoreEnv = mockEnvVars({ NODE_ENV: 'development' });
|
||||
|
||||
utils.logDebug('Debug message');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[DEBUG] Debug message'));
|
||||
});
|
||||
|
||||
it('不应该在生产环境中记录调试消息', () => {
|
||||
restoreEnv = mockEnvVars({ DEBUG: 'false', NODE_ENV: 'production' });
|
||||
|
||||
utils.logDebug('Debug message');
|
||||
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 logInfo 函数', () => {
|
||||
it('应该始终记录信息消息', () => {
|
||||
utils.logInfo('Info message', { data: 'test' });
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[INFO] Info message | {"data":"test"}'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 logWarn 函数', () => {
|
||||
it('应该始终记录警告消息', () => {
|
||||
utils.logWarn('Warning message');
|
||||
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[WARN] Warning message'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 logError 函数', () => {
|
||||
it('应该始终记录错误消息', () => {
|
||||
utils.logError('Error message', { error: 'details' });
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[ERROR] Error message | {"error":"details"}'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 safeJsonParse 函数', () => {
|
||||
it('应该解析有效的 JSON 字符串', () => {
|
||||
const obj = { key: 'value', number: 123 };
|
||||
const jsonString = JSON.stringify(obj);
|
||||
const result = utils.safeJsonParse(jsonString);
|
||||
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
|
||||
it('应该为无效的 JSON 返回默认值', () => {
|
||||
const result = utils.safeJsonParse('invalid json', { default: true });
|
||||
|
||||
expect(result).toEqual({ default: true });
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse JSON'));
|
||||
});
|
||||
|
||||
it('应该在未提供默认值时返回 null', () => {
|
||||
const result = utils.safeJsonParse('invalid json');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理空字符串', () => {
|
||||
const result = utils.safeJsonParse('', 'default');
|
||||
|
||||
expect(result).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 safeJsonStringify 函数', () => {
|
||||
it('应该序列化有效对象', () => {
|
||||
const obj = { key: 'value', number: 123 };
|
||||
const result = utils.safeJsonStringify(obj);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(obj);
|
||||
});
|
||||
|
||||
it('应该为循环引用返回默认值', () => {
|
||||
const obj = {};
|
||||
obj.circular = obj;
|
||||
|
||||
const result = utils.safeJsonStringify(obj, '{"error":"circular"}');
|
||||
|
||||
expect(result).toBe('{"error":"circular"}');
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to stringify object'),
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在未提供默认值时返回空对象', () => {
|
||||
const obj = {};
|
||||
obj.circular = obj;
|
||||
|
||||
const result = utils.safeJsonStringify(obj);
|
||||
|
||||
expect(result).toBe('{}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 isEmpty 函数', () => {
|
||||
it('应该对空字符串返回 true', () => {
|
||||
expect(utils.isEmpty('')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对仅包含空白的字符串返回 true', () => {
|
||||
expect(utils.isEmpty(' ')).toBe(true);
|
||||
expect(utils.isEmpty('\t\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对 null 返回 true', () => {
|
||||
expect(utils.isEmpty(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对 undefined 返回 true', () => {
|
||||
expect(utils.isEmpty(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对非字符串值返回 true', () => {
|
||||
expect(utils.isEmpty(123)).toBe(true);
|
||||
expect(utils.isEmpty({})).toBe(true);
|
||||
expect(utils.isEmpty([])).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对非空字符串返回 false', () => {
|
||||
expect(utils.isEmpty('hello')).toBe(false);
|
||||
expect(utils.isEmpty(' hello ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 truncateString 函数', () => {
|
||||
it('如果字符串短于最大长度应该返回原字符串', () => {
|
||||
const str = 'hello';
|
||||
const result = utils.truncateString(str, 10);
|
||||
|
||||
expect(result).toBe(str);
|
||||
});
|
||||
|
||||
it('应该使用默认后缀截断字符串', () => {
|
||||
const str = 'hello world';
|
||||
const result = utils.truncateString(str, 8);
|
||||
|
||||
expect(result).toBe('hello...');
|
||||
expect(result.length).toBe(8);
|
||||
});
|
||||
|
||||
it('应该使用自定义后缀截断字符串', () => {
|
||||
const str = 'hello world';
|
||||
const result = utils.truncateString(str, 10, ' [more]');
|
||||
|
||||
expect(result).toBe('hel [more]');
|
||||
expect(result.length).toBe(10);
|
||||
});
|
||||
|
||||
it('应该处理 null 输入', () => {
|
||||
expect(utils.truncateString(null, 10)).toBe('');
|
||||
});
|
||||
|
||||
it('应该处理非字符串输入', () => {
|
||||
expect(utils.truncateString(123, 10)).toBe('');
|
||||
});
|
||||
|
||||
it('应该处理最大长度等于后缀长度的边界情况', () => {
|
||||
const result = utils.truncateString('hello', 3);
|
||||
expect(result).toBe('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 cleanMobile 函数', () => {
|
||||
it('应该清理并验证有效的手机号码', () => {
|
||||
expect(utils.cleanMobile('138-0013-8000')).toBe('13800138000');
|
||||
expect(utils.cleanMobile('139 0013 9000')).toBe('13900139000');
|
||||
expect(utils.cleanMobile('(150) 1234-5678')).toBe('15012345678');
|
||||
});
|
||||
|
||||
it('应该对无效的手机号码返回 null', () => {
|
||||
expect(utils.cleanMobile('12345678901')).toBeNull(); // 不是1开头
|
||||
expect(utils.cleanMobile('1234567890')).toBeNull(); // 位数不够
|
||||
expect(utils.cleanMobile('123456789012')).toBeNull(); // 位数太多
|
||||
expect(utils.cleanMobile('12012345678')).toBeNull(); // 第二位不是3-9
|
||||
});
|
||||
|
||||
it('应该对非字符串输入返回 null', () => {
|
||||
expect(utils.cleanMobile(null)).toBeNull();
|
||||
expect(utils.cleanMobile(undefined)).toBeNull();
|
||||
expect(utils.cleanMobile(123)).toBeNull();
|
||||
});
|
||||
|
||||
it('应该对空字符串返回 null', () => {
|
||||
expect(utils.cleanMobile('')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理所有有效的手机号前缀', () => {
|
||||
const validPrefixes = ['13', '14', '15', '16', '17', '18', '19'];
|
||||
validPrefixes.forEach(prefix => {
|
||||
const mobile = `${prefix}012345678`;
|
||||
expect(utils.cleanMobile(mobile)).toBe(mobile);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 isValidUrl 函数', () => {
|
||||
it('应该对有效的 URL 返回 true', () => {
|
||||
expect(utils.isValidUrl('https://example.com')).toBe(true);
|
||||
expect(utils.isValidUrl('http://example.com')).toBe(true);
|
||||
expect(utils.isValidUrl('ftp://example.com')).toBe(true);
|
||||
expect(utils.isValidUrl('https://example.com/path?query=value')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对无效的 URL 返回 false', () => {
|
||||
expect(utils.isValidUrl('not-a-url')).toBe(false);
|
||||
expect(utils.isValidUrl('http://')).toBe(false);
|
||||
expect(utils.isValidUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('应该对非字符串输入返回 false', () => {
|
||||
expect(utils.isValidUrl(null)).toBe(false);
|
||||
expect(utils.isValidUrl(undefined)).toBe(false);
|
||||
expect(utils.isValidUrl(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 isValidHttpUrl 函数', () => {
|
||||
it('应该对有效的 HTTP/HTTPS URL 返回 true', () => {
|
||||
expect(utils.isValidHttpUrl('https://example.com')).toBe(true);
|
||||
expect(utils.isValidHttpUrl('http://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对非 HTTP URL 返回 false', () => {
|
||||
expect(utils.isValidHttpUrl('ftp://example.com')).toBe(false);
|
||||
expect(utils.isValidHttpUrl('file:///path/to/file')).toBe(false);
|
||||
});
|
||||
|
||||
it('应该对无效的 URL 返回 false', () => {
|
||||
expect(utils.isValidHttpUrl('not-a-url')).toBe(false);
|
||||
expect(utils.isValidHttpUrl('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 createError 函数', () => {
|
||||
it('应该只使用消息创建错误', () => {
|
||||
const error = utils.createError('Test error');
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.code).toBeUndefined();
|
||||
expect(error.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该使用消息和代码创建错误', () => {
|
||||
const error = utils.createError('Test error', 'TEST_CODE');
|
||||
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.code).toBe('TEST_CODE');
|
||||
});
|
||||
|
||||
it('应该使用消息、代码和详情创建错误', () => {
|
||||
const details = { key: 'value' };
|
||||
const error = utils.createError('Test error', 'TEST_CODE', details);
|
||||
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.code).toBe('TEST_CODE');
|
||||
expect(error.details).toBe(details);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 setActionOutput 函数', () => {
|
||||
it('应该在未设置 GITHUB_OUTPUT 时使用旧格式', () => {
|
||||
restoreEnv = mockEnvVars({ GITHUB_OUTPUT: '' });
|
||||
|
||||
utils.setActionOutput('test_name', 'test_value');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('::set-output name=test_name::test_value');
|
||||
});
|
||||
|
||||
it('应该在设置 GITHUB_OUTPUT 时使用新的 GitHub Actions 格式', () => {
|
||||
// Create a temporary file path for testing
|
||||
const tempFile = '/tmp/test_github_output';
|
||||
restoreEnv = mockEnvVars({ GITHUB_OUTPUT: tempFile });
|
||||
|
||||
// Since we can't easily mock require('fs') in this context,
|
||||
// we'll test that the function doesn't throw and doesn't log
|
||||
expect(() => {
|
||||
utils.setActionOutput('test_name', 'test_value');
|
||||
}).not.toThrow();
|
||||
|
||||
// Verify that console.log was not called (which would indicate legacy format)
|
||||
expect(consoleSpy.log).not.toHaveBeenCalledWith('::set-output name=test_name::test_value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 setActionFailed 函数', () => {
|
||||
it('应该记录错误并退出进程', () => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
|
||||
utils.setActionFailed('Test failure');
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith('::error::Test failure');
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 getEnv 函数', () => {
|
||||
it('应该返回环境变量值', () => {
|
||||
restoreEnv = mockEnvVars({ TEST_VAR: 'test_value' });
|
||||
|
||||
const result = utils.getEnv('TEST_VAR');
|
||||
|
||||
expect(result).toBe('test_value');
|
||||
});
|
||||
|
||||
it('应该在未设置环境变量时返回默认值', () => {
|
||||
const result = utils.getEnv('NON_EXISTENT_VAR', 'default_value');
|
||||
|
||||
expect(result).toBe('default_value');
|
||||
});
|
||||
|
||||
it('应该在未提供默认值时返回空字符串', () => {
|
||||
const result = utils.getEnv('NON_EXISTENT_VAR');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 isDebugMode 函数', () => {
|
||||
it('应该在 DEBUG=true 时返回 true', () => {
|
||||
restoreEnv = mockEnvVars({ DEBUG: 'true' });
|
||||
|
||||
expect(utils.isDebugMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('应该在 NODE_ENV=development 时返回 true', () => {
|
||||
restoreEnv = mockEnvVars({ NODE_ENV: 'development' });
|
||||
|
||||
expect(utils.isDebugMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('应该在生产模式中返回 false', () => {
|
||||
restoreEnv = mockEnvVars({ DEBUG: 'false', NODE_ENV: 'production' });
|
||||
|
||||
expect(utils.isDebugMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理不区分大小写的 DEBUG 值', () => {
|
||||
restoreEnv = mockEnvVars({ DEBUG: 'TRUE' });
|
||||
|
||||
expect(utils.isDebugMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 formatDuration 函数', () => {
|
||||
it('应该格式化毫秒', () => {
|
||||
expect(utils.formatDuration(500)).toBe('500ms');
|
||||
expect(utils.formatDuration(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('应该格式化秒', () => {
|
||||
expect(utils.formatDuration(1000)).toBe('1.00s');
|
||||
expect(utils.formatDuration(1500)).toBe('1.50s');
|
||||
expect(utils.formatDuration(2345)).toBe('2.35s');
|
||||
});
|
||||
|
||||
it('应该处理零持续时间', () => {
|
||||
expect(utils.formatDuration(0)).toBe('0ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 createRetryConfig 函数', () => {
|
||||
it('应该创建默认重试配置', () => {
|
||||
const config = utils.createRetryConfig();
|
||||
|
||||
expect(config.maxRetries).toBe(3);
|
||||
expect(config.delay).toBe(1000);
|
||||
expect(config.backoffMultiplier).toBe(1.5);
|
||||
expect(typeof config.getDelay).toBe('function');
|
||||
});
|
||||
|
||||
it('应该创建自定义重试配置', () => {
|
||||
const config = utils.createRetryConfig(5, 2000, 2);
|
||||
|
||||
expect(config.maxRetries).toBe(5);
|
||||
expect(config.delay).toBe(2000);
|
||||
expect(config.backoffMultiplier).toBe(2);
|
||||
});
|
||||
|
||||
it('应该为尝试次数计算正确的延迟', () => {
|
||||
const config = utils.createRetryConfig(3, 1000, 2);
|
||||
|
||||
expect(config.getDelay(0)).toBe(1000); // 1000 * 2^0
|
||||
expect(config.getDelay(1)).toBe(2000); // 1000 * 2^1
|
||||
expect(config.getDelay(2)).toBe(4000); // 1000 * 2^2
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 maskSensitiveInfo 函数', () => {
|
||||
it('应该在文本中屏蔽敏感信息', () => {
|
||||
const result = utils.maskSensitiveInfo('1234567890', 2, 2);
|
||||
|
||||
expect(result).toBe('12******90');
|
||||
});
|
||||
|
||||
it('应该使用自定义屏蔽字符', () => {
|
||||
const result = utils.maskSensitiveInfo('1234567890', 2, 2, 'X');
|
||||
|
||||
expect(result).toBe('12XXXXXX90');
|
||||
});
|
||||
|
||||
it('如果字符串太短应该屏蔽整个字符串', () => {
|
||||
const result = utils.maskSensitiveInfo('123', 2, 2);
|
||||
|
||||
expect(result).toBe('***');
|
||||
});
|
||||
|
||||
it('应该处理非字符串输入', () => {
|
||||
expect(utils.maskSensitiveInfo(null)).toBe('');
|
||||
expect(utils.maskSensitiveInfo(123)).toBe('');
|
||||
});
|
||||
|
||||
it('应该处理空字符串', () => {
|
||||
expect(utils.maskSensitiveInfo('')).toBe('');
|
||||
});
|
||||
|
||||
it('应该使用默认参数', () => {
|
||||
const result = utils.maskSensitiveInfo('1234567890123456');
|
||||
|
||||
expect(result).toBe('1234********3456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('测试 validateRequiredEnvVars 函数', () => {
|
||||
it('应该在所有必需变量都存在时不抛出异常', () => {
|
||||
restoreEnv = mockEnvVars({
|
||||
VAR1: 'value1',
|
||||
VAR2: 'value2',
|
||||
VAR3: 'value3',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
utils.validateRequiredEnvVars(['VAR1', 'VAR2', 'VAR3']);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('应该在缺少必需变量时抛出异常', () => {
|
||||
restoreEnv = mockEnvVars({ VAR1: 'value1' });
|
||||
|
||||
expect(() => {
|
||||
utils.validateRequiredEnvVars(['VAR1', 'VAR2', 'VAR3']);
|
||||
}).toThrow('Missing required environment variables: VAR2, VAR3');
|
||||
});
|
||||
|
||||
it('应该抛出带有正确代码和详情的错误', () => {
|
||||
try {
|
||||
utils.validateRequiredEnvVars(['MISSING_VAR']);
|
||||
} catch (error) {
|
||||
assertError(error, 'Missing required environment variables', 'MISSING_ENV_VARS');
|
||||
expect(error.details.missing).toEqual(['MISSING_VAR']);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该处理空数组', () => {
|
||||
expect(() => {
|
||||
utils.validateRequiredEnvVars([]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user