Files
dingtalk-bot/tests/unit/config.test.js

593 lines
21 KiB
JavaScript

/**
* 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:'));
});
});
});