feat: 初始化 Gitea Action 发送钉钉机器人消息项目

This commit is contained in:
ren
2025-10-15 17:52:44 +08:00
commit 428795c6a5
27 changed files with 9262 additions and 0 deletions

View File

@@ -0,0 +1,407 @@
/**
* 无效配置数据夹具
* 包含各种无效的配置组合用于测试错误处理
*/
export const invalidConfigs = {
// 环境变量格式的无效配置
envVars: {
missingWebhook: {
INPUT_WEBHOOK_URL: '',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: 'Hello World',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: ''
},
invalidWebhook: {
INPUT_WEBHOOK_URL: 'not-a-valid-url',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: 'Hello World',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: ''
},
emptyContent: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: '',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: ''
}
},
// 缺少 webhook_url
missingWebhookUrl: {
webhook_url: '',
message_type: 'text',
content: 'Hello, World!',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// 无效的 webhook_url
invalidWebhookUrl: {
webhook_url: 'not-a-valid-url',
message_type: 'text',
content: 'Hello, World!',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// 不支持的消息类型
unsupportedMessageType: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'unsupported',
content: 'Hello, World!',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// 空的消息内容
emptyContent: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'text',
content: '',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// Markdown 消息缺少标题
markdownMissingTitle: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'markdown',
content: '## 内容',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// Link 消息缺少 URL
linkMissingUrl: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'link',
content: '链接描述',
title: '链接标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// Link 消息无效的 URL
linkInvalidUrl: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'link',
content: '链接描述',
title: '链接标题',
at_mobiles: '',
at_all: false,
link_url: 'not-a-valid-url',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// ActionCard 缺少标题
actionCardMissingTitle: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'actionCard',
content: 'ActionCard 内容',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '按钮',
single_url: 'https://example.com',
buttons: '',
feed_links: ''
},
// ActionCard 既没有单按钮也没有多按钮
actionCardNoButtons: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'actionCard',
content: 'ActionCard 内容',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// ActionCard 单按钮缺少 URL
actionCardSingleMissingUrl: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'actionCard',
content: 'ActionCard 内容',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '按钮',
single_url: '',
buttons: '',
feed_links: ''
},
// ActionCard 多按钮格式错误
actionCardInvalidButtons: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'actionCard',
content: 'ActionCard 内容',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: 'invalid-json',
feed_links: ''
},
// ActionCard 多按钮为空数组
actionCardEmptyButtons: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'actionCard',
content: 'ActionCard 内容',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '[]',
feed_links: ''
},
// ActionCard 按钮缺少必需字段
actionCardButtonMissingFields: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'actionCard',
content: 'ActionCard 内容',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: JSON.stringify([
{ title: '按钮1' }, // 缺少 actionURL
{ actionURL: 'https://example.com' } // 缺少 title
]),
feed_links: ''
},
// FeedCard 缺少链接
feedCardMissingLinks: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'feedCard',
content: '',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// FeedCard 链接格式错误
feedCardInvalidLinks: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'feedCard',
content: '',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: 'invalid-json'
},
// FeedCard 空链接数组
feedCardEmptyLinks: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'feedCard',
content: '',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: '[]'
},
// FeedCard 链接缺少必需字段
feedCardLinkMissingFields: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'feedCard',
content: '',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: JSON.stringify([
{ title: '链接1' }, // 缺少 messageURL
{ messageURL: 'https://example.com' } // 缺少 title
])
},
// 无效的手机号格式
invalidMobileFormat: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'text',
content: 'Hello, World!',
title: '',
at_mobiles: 'invalid-mobile',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: ''
},
// 无效的按钮方向
invalidBtnOrientation: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'actionCard',
content: 'ActionCard 内容',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '2', // 只支持 0 和 1
single_title: '按钮',
single_url: 'https://example.com',
buttons: '',
feed_links: ''
}
};
// 环境变量格式的无效配置
export const invalidEnvConfigs = {
missingWebhookUrl: {
INPUT_WEBHOOK_URL: '',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: 'Hello, World!'
},
unsupportedMessageType: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
INPUT_MESSAGE_TYPE: 'unsupported',
INPUT_CONTENT: 'Hello, World!'
},
emptyContent: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: ''
}
};

378
tests/fixtures/configs/valid-configs.js vendored Normal file
View File

@@ -0,0 +1,378 @@
/**
* 有效配置数据夹具
* 包含各种有效的配置组合用于测试
*/
export const validConfigs = {
// 环境变量格式的配置
envVars: {
text: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: 'Hello World',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: '',
},
markdown: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test456',
INPUT_MESSAGE_TYPE: 'markdown',
INPUT_CONTENT: '# Hello\n\nThis is **markdown** content.',
INPUT_TITLE: 'Test Title',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: '',
},
link: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test789',
INPUT_MESSAGE_TYPE: 'link',
INPUT_CONTENT: 'This is a test link',
INPUT_TITLE: 'Test Link',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: 'https://example.com',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: '',
},
actionCard: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test101',
INPUT_MESSAGE_TYPE: 'actionCard',
INPUT_CONTENT: 'Action card content',
INPUT_TITLE: 'Action Card Title',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: JSON.stringify([{ title: 'Button 1', actionURL: 'https://example.com/1' }]),
INPUT_FEED_LINKS: '',
},
feedCard: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test103',
INPUT_MESSAGE_TYPE: 'feedCard',
INPUT_CONTENT: '',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: JSON.stringify([
{
title: '新闻1',
messageURL: 'https://example.com/news1',
picURL: 'https://example.com/pic1.jpg',
},
]),
},
},
// 基础文本消息配置
basicText: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
message_type: 'text',
content: 'Hello, World!',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: '',
},
// Markdown 消息配置
markdown: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test456',
message_type: 'markdown',
content: '## 标题\n这是一个 **Markdown** 消息',
title: 'Markdown 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: '',
},
// 链接消息配置
link: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test789',
message_type: 'link',
content: '这是一个链接消息的描述',
title: '链接标题',
at_mobiles: '',
at_all: false,
link_url: 'https://example.com',
pic_url: 'https://example.com/image.jpg',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: '',
},
// 单按钮 ActionCard 配置
singleActionCard: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test101',
message_type: 'actionCard',
content: '这是一个单按钮 ActionCard',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '查看详情',
single_url: 'https://example.com/detail',
buttons: '',
feed_links: '',
},
// 多按钮 ActionCard 配置
multiActionCard: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test102',
message_type: 'actionCard',
content: '这是一个多按钮 ActionCard',
title: 'ActionCard 标题',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '1',
single_title: '',
single_url: '',
buttons: JSON.stringify([
{ title: '按钮1', actionURL: 'https://example.com/action1' },
{ title: '按钮2', actionURL: 'https://example.com/action2' },
]),
feed_links: '',
},
// FeedCard 配置
feedCard: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test103',
message_type: 'feedCard',
content: '',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: 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',
},
]),
},
// 带 @ 功能的文本消息
textWithAt: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test104',
message_type: 'text',
content: '这是一个带 @ 功能的消息',
title: '',
at_mobiles: '13800138000,13900139000',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: '',
},
// @ 所有人的消息
textWithAtAll: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test105',
message_type: 'text',
content: '这是一个 @ 所有人的消息',
title: '',
at_mobiles: '',
at_all: true,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: '',
},
// 复杂的 Markdown 消息
complexMarkdown: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test106',
message_type: 'markdown',
content: `# 构建报告
## 构建状态: ✅ 成功
- **项目**: dingtalk-bot
- **分支**: main
- **提交**: abc123
- **时间**: 2024-01-01 12:00:00
### 测试结果
- 单元测试: 100% 通过
- 覆盖率: 95%
[查看详细报告](https://example.com/report)`,
title: '构建报告',
at_mobiles: '13800138000',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: '',
},
// 垂直排列的多按钮 ActionCard
verticalActionCard: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test107',
message_type: 'actionCard',
content: '请选择操作',
title: '操作选择',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '1',
single_title: '',
single_url: '',
buttons: JSON.stringify([
{ title: '同意', actionURL: 'https://example.com/approve' },
{ title: '拒绝', actionURL: 'https://example.com/reject' },
{ title: '查看详情', actionURL: 'https://example.com/detail' },
]),
feed_links: '',
},
// 多链接 FeedCard
multiFeedCard: {
webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test108',
message_type: 'feedCard',
content: '',
title: '',
at_mobiles: '',
at_all: false,
link_url: '',
pic_url: '',
btn_orientation: '0',
single_title: '',
single_url: '',
buttons: '',
feed_links: JSON.stringify([
{
title: '技术文档',
messageURL: 'https://docs.example.com',
picURL: 'https://example.com/doc.jpg',
},
{
title: 'API 参考',
messageURL: 'https://api.example.com',
picURL: 'https://example.com/api.jpg',
},
{
title: '示例代码',
messageURL: 'https://github.com/example',
picURL: 'https://example.com/code.jpg',
},
]),
},
};
// 环境变量格式的配置(用于测试 config.js
export const envConfigs = {
basicText: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: 'Hello, World!',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: '',
},
markdown: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test456',
INPUT_MESSAGE_TYPE: 'markdown',
INPUT_CONTENT: '## 标题\n这是一个 **Markdown** 消息',
INPUT_TITLE: 'Markdown 标题',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: '',
},
textWithAtAll: {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test105',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: '这是一个 @ 所有人的消息',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'true',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: '',
},
};

View File

@@ -0,0 +1,366 @@
/**
* 钉钉 API 响应数据夹具
* 包含各种钉钉 API 响应用于测试
*/
// 成功响应
export const successResponses = {
// 标准成功响应
standard: {
errcode: 0,
errmsg: 'ok',
},
// 带消息 ID 的成功响应
withMessageId: {
errcode: 0,
errmsg: 'ok',
message_id: 'msg789012',
},
};
// 错误响应
export const errorResponses = {
// 无效的 access_token
invalidToken: {
errcode: 310000,
errmsg: 'keywords not in content',
},
// 消息内容中不包含任何关键词
noKeywords: {
errcode: 310000,
errmsg: 'keywords not in content',
},
// 消息发送频率超过限制
rateLimited: {
errcode: 130101,
errmsg: 'send too fast',
},
// 机器人被停用
robotDisabled: {
errcode: 300001,
errmsg: 'robot is disabled',
},
// 签名验证失败
signatureError: {
errcode: 310000,
errmsg: 'sign not match',
},
// IP 地址不在白名单中
ipNotAllowed: {
errcode: 310000,
errmsg: 'ip not allow',
},
// 消息格式错误
invalidFormat: {
errcode: 300002,
errmsg: 'param is invalid',
},
// 消息内容为空
emptyContent: {
errcode: 300003,
errmsg: 'content is empty',
},
// 消息长度超过限制
contentTooLong: {
errcode: 300004,
errmsg: 'content too long',
},
// @ 的手机号格式错误
invalidMobile: {
errcode: 300005,
errmsg: 'mobile format error',
},
// ActionCard 按钮数量超过限制
tooManyButtons: {
errcode: 300006,
errmsg: 'too many buttons',
},
// FeedCard 链接数量超过限制
tooManyLinks: {
errcode: 300007,
errmsg: 'too many links',
},
// 服务器内部错误
internalError: {
errcode: 500000,
errmsg: 'internal server error',
},
// 服务不可用
serviceUnavailable: {
errcode: 503000,
errmsg: 'service unavailable',
},
};
// HTTP 状态码响应
export const httpStatusResponses = {
// 200 OK
ok: {
statusCode: 200,
data: successResponses.standard,
},
// 400 Bad Request
badRequest: {
statusCode: 400,
data: {
errcode: 400000,
errmsg: 'bad request',
},
},
// 401 Unauthorized
unauthorized: {
statusCode: 401,
data: {
errcode: 401000,
errmsg: 'unauthorized',
},
},
// 403 Forbidden
forbidden: {
statusCode: 403,
data: {
errcode: 403000,
errmsg: 'forbidden',
},
},
// 404 Not Found
notFound: {
statusCode: 404,
data: {
errcode: 404000,
errmsg: 'not found',
},
},
// 429 Too Many Requests
tooManyRequests: {
statusCode: 429,
data: errorResponses.rateLimited,
},
// 500 Internal Server Error
internalServerError: {
statusCode: 500,
data: errorResponses.internalError,
},
// 502 Bad Gateway
badGateway: {
statusCode: 502,
data: {
errcode: 502000,
errmsg: 'bad gateway',
},
},
// 503 Service Unavailable
serviceUnavailable: {
statusCode: 503,
data: errorResponses.serviceUnavailable,
},
// 504 Gateway Timeout
gatewayTimeout: {
statusCode: 504,
data: {
errcode: 504000,
errmsg: 'gateway timeout',
},
},
};
// 网络错误响应
export const networkErrors = {
// 连接被拒绝
connectionRefused: {
code: 'ECONNREFUSED',
message: 'connect ECONNREFUSED 127.0.0.1:443',
},
// 连接超时
connectionTimeout: {
code: 'ETIMEDOUT',
message: 'connect ETIMEDOUT',
},
// DNS 解析失败
dnsError: {
code: 'ENOTFOUND',
message: 'getaddrinfo ENOTFOUND oapi.dingtalk.com',
},
// 网络不可达
networkUnreachable: {
code: 'ENETUNREACH',
message: 'network is unreachable',
},
// 连接被重置
connectionReset: {
code: 'ECONNRESET',
message: 'socket hang up',
},
// SSL/TLS 错误
sslError: {
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
message: 'self signed certificate',
},
};
// 特殊响应场景
export const specialResponses = {
// 空响应体
emptyBody: {
statusCode: 200,
data: '',
},
// 非 JSON 响应
nonJsonResponse: {
statusCode: 200,
data: 'This is not JSON',
},
// 格式错误的 JSON
malformedJson: {
statusCode: 200,
data: '{"errcode": 0, "errmsg": "ok"',
},
// 缺少必需字段的响应
missingFields: {
statusCode: 200,
data: {
errcode: 0,
// 缺少 errmsg
},
},
// 字段类型错误的响应
wrongFieldTypes: {
statusCode: 200,
data: {
errcode: '0', // 应该是数字
errmsg: 123, // 应该是字符串
},
},
};
// 响应时间模拟
export const responseTimings = {
// 快速响应 (< 100ms)
fast: 50,
// 正常响应 (100-500ms)
normal: 200,
// 慢响应 (500-2000ms)
slow: 1000,
// 非常慢的响应 (> 2000ms)
verySlow: 3000,
// 超时响应 (> 5000ms)
timeout: 6000,
};
// 批量响应场景
export const batchResponses = {
// 全部成功
allSuccess: [
successResponses.standard,
successResponses.withMessageId,
successResponses.standard,
],
// 部分失败
partialFailure: [
successResponses.standard,
errorResponses.rateLimited,
successResponses.standard,
],
// 全部失败
allFailure: [
errorResponses.invalidToken,
errorResponses.rateLimited,
errorResponses.robotDisabled,
],
};
// 用于测试的完整响应对象
export const fullResponses = {
textSuccess: {
request: {
msgtype: 'text',
text: {
content: 'Hello, World!',
},
at: {
atMobiles: [],
atUserIds: [],
isAtAll: false,
},
},
response: successResponses.standard,
},
markdownSuccess: {
request: {
msgtype: 'markdown',
markdown: {
title: 'Markdown 标题',
text: '## 标题\n这是一个 **Markdown** 消息',
},
at: {
atMobiles: [],
atUserIds: [],
isAtAll: false,
},
},
response: successResponses.withMessageId,
},
actionCardError: {
request: {
msgtype: 'actionCard',
actionCard: {
title: 'ActionCard 标题',
text: 'ActionCard 内容',
singleTitle: '查看详情',
singleURL: 'https://example.com',
},
},
response: errorResponses.noKeywords,
},
};
export const dingTalkResponses = {
success: successResponses,
error: errorResponses,
httpStatus: httpStatusResponses,
network: networkErrors,
special: specialResponses,
timing: responseTimings,
batch: batchResponses,
full: fullResponses,
};

243
tests/helpers/assertions.js Normal file
View File

@@ -0,0 +1,243 @@
/**
* 自定义断言工具
* 提供项目特定的断言方法
*/
import { expect } from 'vitest';
/**
* 验证钉钉消息格式
*
* @param {object} message - 消息对象
* @param {string} expectedType - 期望的消息类型
*/
export function assertDingTalkMessage(message, expectedType) {
expect(message).toBeDefined();
expect(message).toBeTypeOf('object');
expect(message.msgtype).toBe(expectedType);
switch (expectedType) {
case 'text':
expect(message.text).toBeDefined();
expect(message.text.content).toBeTypeOf('string');
break;
case 'markdown':
expect(message.markdown).toBeDefined();
expect(message.markdown.title).toBeTypeOf('string');
expect(message.markdown.text).toBeTypeOf('string');
break;
case 'link':
expect(message.link).toBeDefined();
expect(message.link.title).toBeTypeOf('string');
expect(message.link.text).toBeTypeOf('string');
expect(message.link.messageUrl).toBeTypeOf('string');
break;
case 'actionCard':
expect(message.actionCard).toBeDefined();
expect(message.actionCard.title).toBeTypeOf('string');
expect(message.actionCard.text).toBeTypeOf('string');
break;
case 'feedCard':
expect(message.feedCard).toBeDefined();
expect(message.feedCard.links).toBeInstanceOf(Array);
break;
}
}
/**
* 验证 @ 功能配置
*
* @param {object} message - 消息对象
* @param {object} expectedAt - 期望的 @ 配置
*/
export function assertAtConfiguration(message, expectedAt) {
expect(message.at).toBeDefined();
if (expectedAt.atMobiles) {
expect(message.at.atMobiles).toEqual(expectedAt.atMobiles);
}
if (expectedAt.atUserIds) {
expect(message.at.atUserIds).toEqual(expectedAt.atUserIds);
}
if (expectedAt.isAtAll !== undefined) {
expect(message.at.isAtAll).toBe(expectedAt.isAtAll);
}
}
/**
* 验证 ActionCard 按钮配置
*
* @param {object} actionCard - ActionCard 对象
* @param {object} expectedButtons - 期望的按钮配置
*/
export function assertActionCardButtons(actionCard, expectedButtons) {
if (expectedButtons.single) {
expect(actionCard.singleTitle).toBe(expectedButtons.single.title);
expect(actionCard.singleURL).toBe(expectedButtons.single.url);
expect(actionCard.btns).toBeUndefined();
} else if (expectedButtons.multiple) {
expect(actionCard.btns).toBeInstanceOf(Array);
expect(actionCard.btns).toHaveLength(expectedButtons.multiple.length);
expectedButtons.multiple.forEach((expectedBtn, index) => {
expect(actionCard.btns[index].title).toBe(expectedBtn.title);
expect(actionCard.btns[index].actionURL).toBe(expectedBtn.actionURL);
});
expect(actionCard.singleTitle).toBeUndefined();
expect(actionCard.singleURL).toBeUndefined();
}
if (expectedButtons.orientation !== undefined) {
expect(actionCard.btnOrientation).toBe(expectedButtons.orientation);
}
}
/**
* 验证 FeedCard 链接配置
*
* @param {object} feedCard - FeedCard 对象
* @param {Array} expectedLinks - 期望的链接配置
*/
export function assertFeedCardLinks(feedCard, expectedLinks) {
expect(feedCard.links).toBeInstanceOf(Array);
expect(feedCard.links).toHaveLength(expectedLinks.length);
expectedLinks.forEach((expectedLink, index) => {
const link = feedCard.links[index];
expect(link.title).toBe(expectedLink.title);
expect(link.messageURL).toBe(expectedLink.messageURL);
if (expectedLink.picURL) {
expect(link.picURL).toBe(expectedLink.picURL);
}
});
}
/**
* 验证配置对象结构
*
* @param {object} config - 配置对象
* @param {Array} requiredFields - 必需字段列表
*/
export function assertConfigStructure(config, requiredFields = []) {
expect(config).toBeDefined();
expect(config).toBeTypeOf('object');
requiredFields.forEach(field => {
expect(config).toHaveProperty(field);
});
}
/**
* 验证错误对象
*
* @param {Error} error - 错误对象
* @param {string} expectedMessage - 期望的错误消息(可选)
* @param {string} expectedCode - 期望的错误代码(可选)
*/
export function assertError(error, expectedMessage, expectedCode) {
expect(error).toBeInstanceOf(Error);
if (expectedMessage) {
expect(error.message).toContain(expectedMessage);
}
if (expectedCode) {
expect(error.code).toBe(expectedCode);
}
}
/**
* 验证 HTTP 请求选项
*
* @param {object} options - 请求选项
* @param {object} expected - 期望的选项
*/
export function assertHttpOptions(options, expected) {
expect(options).toBeDefined();
expect(options).toBeTypeOf('object');
if (expected.hostname) {
expect(options.hostname).toBe(expected.hostname);
}
if (expected.path) {
expect(options.path).toBe(expected.path);
}
if (expected.method) {
expect(options.method).toBe(expected.method);
}
if (expected.headers) {
expect(options.headers).toEqual(expect.objectContaining(expected.headers));
}
}
/**
* 验证 URL 格式
*
* @param {string} url - URL 字符串
*/
export function assertValidUrl(url) {
expect(url).toBeTypeOf('string');
expect(url.length).toBeGreaterThan(0);
expect(() => new URL(url)).not.toThrow();
}
/**
* 验证 JSON 字符串
*
* @param {string} jsonString - JSON 字符串
* @returns {object} 解析后的对象
*/
export function assertValidJson(jsonString) {
expect(jsonString).toBeTypeOf('string');
let parsed;
expect(() => {
parsed = JSON.parse(jsonString);
}).not.toThrow();
return parsed;
}
/**
* 验证数组包含特定元素
*
* @param {Array} array - 数组
* @param {*} element - 要查找的元素
*/
export function assertArrayContains(array, element) {
expect(array).toBeInstanceOf(Array);
expect(array).toContain(element);
}
/**
* 验证对象深度相等
*
* @param {object} actual - 实际对象
* @param {object} expected - 期望对象
*/
export function assertDeepEqual(actual, expected) {
expect(actual).toEqual(expected);
}
/**
* 验证函数抛出特定错误
*
* @param {Function} fn - 要测试的函数
* @param {string|RegExp} expectedError - 期望的错误消息或正则表达式
*/
export function assertThrows(fn, expectedError) {
if (typeof expectedError === 'string') {
expect(fn).toThrow(expectedError);
} else if (expectedError instanceof RegExp) {
expect(fn).toThrow(expectedError);
} else {
expect(fn).toThrow();
}
}

111
tests/helpers/env-mock.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* 环境变量 Mock 工具
* 用于在测试中模拟和管理环境变量
*/
/**
* Mock 环境变量
*
* @param {object} envVars - 要设置的环境变量
* @returns {Function} 恢复函数
*/
export function mockEnvVars(envVars) {
const originalEnv = {};
// 保存原始值
for (const key in envVars) {
originalEnv[key] = process.env[key];
process.env[key] = envVars[key];
}
// 返回恢复函数
return () => {
for (const key in envVars) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
};
}
/**
* 创建标准的 Action 环境变量
*
* @param {object} overrides - 覆盖的环境变量
* @returns {object} 环境变量对象
*/
export function createActionEnv(overrides = {}) {
return {
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: 'Test message',
INPUT_TITLE: '',
INPUT_AT_MOBILES: '',
INPUT_AT_ALL: 'false',
INPUT_LINK_URL: '',
INPUT_PIC_URL: '',
INPUT_BTN_ORIENTATION: '0',
INPUT_SINGLE_TITLE: '',
INPUT_SINGLE_URL: '',
INPUT_BUTTONS: '',
INPUT_FEED_LINKS: '',
...overrides,
};
}
/**
* 清理所有 INPUT_ 开头的环境变量
*
* @returns {Function} 恢复函数
*/
export function clearActionEnv() {
const originalEnv = {};
const inputKeys = Object.keys(process.env).filter(key => key.startsWith('INPUT_'));
inputKeys.forEach(key => {
originalEnv[key] = process.env[key];
delete process.env[key];
});
return () => {
Object.assign(process.env, originalEnv);
};
}
/**
* 创建测试用的环境变量快照
*
* @returns {object} 当前环境变量的副本
*/
export function createEnvSnapshot() {
return { ...process.env };
}
/**
* 恢复环境变量到指定快照
*
* @param {object} snapshot - 环境变量快照
*/
export function restoreEnvSnapshot(snapshot) {
// 清除当前环境变量
for (const key in process.env) {
if (!(key in snapshot)) {
delete process.env[key];
}
}
// 恢复快照中的环境变量
Object.assign(process.env, snapshot);
}
/**
* 创建环境变量 Mock别名函数
*
* @param {object} envVars - 要设置的环境变量
* @returns {Function} 恢复函数
*/
export function createEnvMock(envVars) {
return mockEnvVars(envVars);
}

267
tests/helpers/http-mock.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* HTTP Mock 工具
* 用于在测试中模拟 HTTP 请求和响应
*/
import { EventEmitter } from 'events';
import { vi } from 'vitest';
/**
* Mock HTTP 响应
*/
export class MockResponse extends EventEmitter {
constructor(statusCode = 200, data = {}) {
super();
this.statusCode = statusCode;
this.data = data;
this.headers = {};
}
emit(event, ...args) {
// 异步触发事件,模拟真实网络延迟
setImmediate(() => super.emit(event, ...args));
}
simulateResponse() {
// 如果 data 已经是字符串,直接使用;否则进行 JSON 序列化
const responseData = typeof this.data === 'string' ? this.data : JSON.stringify(this.data);
this.emit('data', responseData);
this.emit('end');
}
simulateError(error) {
this.emit('error', error);
}
simulateTimeout() {
this.emit('timeout');
}
setHeader(name, value) {
this.headers[name] = value;
}
getHeader(name) {
return this.headers[name];
}
}
/**
* Mock HTTP 请求
*/
export class MockRequest extends EventEmitter {
constructor() {
super();
this.written = [];
this.ended = false;
this.destroyed = false;
this.headers = {};
}
write(data) {
this.written.push(data);
return true;
}
end(data) {
if (data) {
this.write(data);
}
this.ended = true;
this.emit('end');
}
destroy() {
this.destroyed = true;
this.emit('close');
}
setTimeout(timeout, callback) {
setTimeout(() => {
if (!this.ended && !this.destroyed) {
this.emit('timeout');
if (callback) callback();
}
}, timeout);
}
setHeader(name, value) {
this.headers[name] = value;
}
getHeader(name) {
return this.headers[name];
}
getWrittenData() {
return this.written.join('');
}
}
/**
* 创建模拟的 HTTPS 模块用于测试
*
* @typedef {object} HttpsMockModule
* @property {import('vitest').Mock} request - 模拟的 HTTPS 请求方法
* @property {function(): Array} getRequests - 获取所有已发送的请求列表
* @property {function(number, any): void} addResponse - 添加预定义的响应
* @property {function(Error): void} addErrorResponse - 添加错误响应
* @property {function(): void} addTimeoutResponse - 添加超时响应
* @property {function(any): void} mockRequestOnce - 为单次请求设置响应
* @property {function(): void} reset - 重置所有请求和响应状态
* @property {function(): object} getLastRequest - 获取最后一次请求的详细信息
* @property {function(): number} getRequestCount - 获取已发送请求的数量
*
* @returns {HttpsMockModule} 模拟的 HTTPS 模块对象
*/
export function createHttpsMock() {
const requests = [];
const responses = [];
const mockHttps = {
request: vi.fn((options, callback) => {
const req = new MockRequest();
const res = responses.shift() || new MockResponse();
requests.push({ options, req, res });
// 异步调用回调
setImmediate(() => {
if (callback) callback(res);
res.simulateResponse();
});
return req;
}),
// 测试辅助方法
getRequests: () => requests,
addResponse: (statusCode, data) => {
responses.push(new MockResponse(statusCode, data));
},
addErrorResponse: error => {
const res = new MockResponse();
responses.push(res);
setImmediate(() => res.simulateError(error));
},
addTimeoutResponse: () => {
const res = new MockResponse();
responses.push(res);
setImmediate(() => res.simulateTimeout());
},
// 添加单次请求响应
mockRequestOnce: responseData => {
if (typeof responseData === 'object' && responseData.statusCode !== undefined) {
// 如果传入的是包含 statusCode 的对象
responses.push(
new MockResponse(responseData.statusCode, responseData.data || responseData),
);
} else {
// 如果传入的是普通响应数据,默认使用 200 状态码
responses.push(new MockResponse(200, responseData));
}
},
reset: () => {
requests.length = 0;
responses.length = 0;
mockHttps.request.mockClear();
},
// 获取最后一次请求的详细信息
getLastRequest: () => {
return requests[requests.length - 1];
},
// 获取请求数量
getRequestCount: () => requests.length,
};
return mockHttps;
}
/**
* 创建简单的 HTTP Mock
*
* @param {number|object} statusCodeOrResponse - 状态码或包含 statusCode 和 data 的响应对象
* @param {object} data - 响应数据(当第一个参数是状态码时使用)
* @returns {object} Mock 对象
*/
export function createSimpleHttpMock(statusCodeOrResponse = 200, data = {}) {
const mockHttps = createHttpsMock();
if (typeof statusCodeOrResponse === 'object' && statusCodeOrResponse !== null) {
// 如果传入的是响应对象,检查是否有 statusCode 和 data 属性
if (statusCodeOrResponse.statusCode && statusCodeOrResponse.data) {
mockHttps.addResponse(statusCodeOrResponse.statusCode, statusCodeOrResponse.data);
} else {
// 如果只是数据对象,默认使用 200 状态码
mockHttps.addResponse(200, statusCodeOrResponse);
}
} else {
// 传统的调用方式:状态码和数据分开传递
mockHttps.addResponse(statusCodeOrResponse, data);
}
return mockHttps;
}
/**
* 创建网络错误 Mock
*
* @param {string} errorCode - 错误代码
* @returns {object} Mock 对象
*/
export function createNetworkErrorMock(errorCode = 'ECONNREFUSED') {
const mockHttps = createHttpsMock();
const error = new Error(`Network error: ${errorCode}`);
error.code = errorCode;
mockHttps.addErrorResponse(error);
return mockHttps;
}
/**
* 创建超时 Mock
*
* @returns {object} Mock 对象
*/
export function createTimeoutMock() {
const mockHttps = createHttpsMock();
mockHttps.addTimeoutResponse();
return mockHttps;
}
/**
* 验证请求选项
*
* @param {object} request - 请求对象
* @param {object} expectedOptions - 期望的选项
*/
export function assertRequestOptions(request, expectedOptions) {
const { options } = request;
for (const [key, value] of Object.entries(expectedOptions)) {
if (options[key] !== value) {
throw new Error(`Expected ${key} to be ${value}, but got ${options[key]}`);
}
}
}
/**
* 验证请求体
*
* @param {object} request - 请求对象
* @param {object} expectedBody - 期望的请求体
*/
export function assertRequestBody(request, expectedBody) {
const actualBody = JSON.parse(request.req.getWrittenData());
for (const [key, value] of Object.entries(expectedBody)) {
if (JSON.stringify(actualBody[key]) !== JSON.stringify(value)) {
throw new Error(
`Expected ${key} to be ${JSON.stringify(value)}, but got ${JSON.stringify(actualBody[key])}`,
);
}
}
}

View File

@@ -0,0 +1,495 @@
/**
* index.js 集成测试
* 测试主入口文件的完整执行流程
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { main } from '../../src/index.js';
import { invalidConfigs } from '../fixtures/configs/invalid-configs.js';
import { validConfigs } from '../fixtures/configs/valid-configs.js';
import { dingTalkResponses } from '../fixtures/responses/dingtalk-responses.js';
import {
clearActionEnv,
createEnvMock,
createEnvSnapshot,
restoreEnvSnapshot,
} from '../helpers/env-mock.js';
// Mock fetch API
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
describe('index.js 集成测试', () => {
let envSnapshot;
let consoleSpy;
/**
* 设置 HTTP Mock 的辅助函数
*
* @param {object} mockResponse - 模拟的 HTTP 响应,格式: { statusCode, data }
*/
async function setupHttpMock(mockResponse) {
const { statusCode = 200, data = {} } = mockResponse;
const responseText = typeof data === 'string' ? data : JSON.stringify(data);
mockFetch.mockResolvedValueOnce({
status: statusCode,
text: async () => responseText,
});
}
/**
* 设置网络错误 Mock 的辅助函数
*
* @param {string} errorMessage - 错误消息
*/
async function setupNetworkErrorMock(errorMessage = 'Network error') {
mockFetch.mockRejectedValueOnce(new Error(errorMessage));
}
beforeEach(async () => {
// 保存环境变量快照
envSnapshot = createEnvSnapshot();
// 清理 Action 环境变量
clearActionEnv();
// Mock console 方法
consoleSpy = {
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
};
// Mock process.exit
vi.spyOn(process, 'exit').mockImplementation(() => {});
// Mock Date.now for consistent timing
vi.spyOn(Date, 'now').mockReturnValue(1640995200000); // 2022-01-01 00:00:00
});
afterEach(() => {
// 恢复环境变量
restoreEnvSnapshot(envSnapshot);
// 清理 fetch mock
mockFetch.mockClear();
// 恢复所有 mocks
vi.restoreAllMocks();
});
describe('成功场景测试', () => {
it('应该成功发送文本消息', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.withMessageId });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
expect(result.messageId).toBe('msg789012');
expect(result.details).toMatchObject({
duration: expect.any(Number),
statusCode: 200,
messageSummary: expect.stringContaining('text'),
});
// 验证日志输出
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('🚀 DingTalk Gitea Action started'),
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('✅ Message sent successfully!'),
);
// 验证 fetch 被调用
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('应该成功发送 Markdown 消息', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.markdown);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
expect(result.details.messageSummary).toContain('markdown');
// 验证 fetch 被调用
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('应该成功发送 Link 消息', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.link);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
expect(result.details.messageSummary).toContain('link');
// 验证 fetch 被调用
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('应该成功发送 ActionCard 消息', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.actionCard);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
expect(result.details.messageSummary).toContain('actionCard');
// 验证 fetch 被调用
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('应该成功发送 FeedCard 消息', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.feedCard);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
expect(result.details.messageSummary).toContain('feedCard');
// 验证 fetch 被调用
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('应该在调试模式下输出详细信息', async () => {
// 设置环境变量(包含调试模式)
createEnvMock({
...validConfigs.envVars.text,
DEBUG: 'true',
});
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
// 验证调试日志
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('[INFO] Debug mode: true'),
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('[DEBUG] Message payload:'),
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('[DEBUG] Full response:'),
);
});
});
describe('失败场景测试', () => {
it('应该处理配置解析错误', async () => {
// 设置无效的环境变量
createEnvMock(invalidConfigs.envVars.missingWebhook);
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
expect(result.error).toContain('Action execution failed');
expect(result.details.errorType).toBe('Error');
// 验证错误日志
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining('Action execution failed'),
);
});
it('应该处理消息验证错误', async () => {
// 设置会导致消息验证失败的环境变量
createEnvMock({
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test',
INPUT_MESSAGE_TYPE: 'text',
INPUT_CONTENT: '', // 空内容会导致验证失败
});
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
expect(result.error).toContain('Action execution failed');
});
it('应该处理 DingTalk API 错误', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock HTTP 请求返回错误
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.error.invalidToken });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to send message');
expect(result.details.errcode).toBe(310000);
expect(result.details.errmsg).toBe('keywords not in content');
// 验证错误日志
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to send message'),
);
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining('DingTalk error code: 310000'),
);
});
it('应该处理网络错误', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock 网络错误
await setupNetworkErrorMock('ECONNREFUSED');
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
expect(result.details.networkError).toContain('ECONNREFUSED');
// 验证错误日志
expect(consoleSpy.error).toHaveBeenCalledWith(expect.stringContaining('Network error'));
});
it('应该处理超时错误', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock 超时错误
await setupNetworkErrorMock('ETIMEDOUT');
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
expect(result.details.networkError).toContain('ETIMEDOUT');
});
});
describe('边界条件测试', () => {
it('应该处理响应验证失败但仍然成功的情况', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock 返回格式不完整但成功的响应(缺少 errmsg 字段)
await setupHttpMock({
statusCode: 200,
data: {
errcode: 0,
// 缺少 errmsg 字段,这会触发验证失败
},
});
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
expect(result.messageId).toBeUndefined();
// 验证警告日志
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining('Response validation failed'),
);
});
it('应该处理空的错误消息', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock 返回空错误消息的失败响应
await setupHttpMock({
statusCode: 400,
data: {
errcode: 300001,
errmsg: '',
},
});
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to send message');
});
it('应该正确计算执行时间', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// Mock Date.now 返回递增的时间
let callCount = 0;
vi.spyOn(Date, 'now').mockImplementation(() => {
callCount++;
return 1640995200000 + callCount * 1000; // 每次调用增加1秒
});
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
expect(result.details.duration).toBeGreaterThan(0);
// 验证执行时间日志
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Total execution time:'));
});
});
describe('Action 输出测试', () => {
it('应该在成功时设置正确的 Action 输出', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.withMessageId });
// Mock GitHub Actions 输出
const outputSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
// 验证 Action 输出
expect(outputSpy).toHaveBeenCalledWith('::set-output name=success::true');
expect(outputSpy).toHaveBeenCalledWith('::set-output name=message_id::msg789012');
expect(outputSpy).toHaveBeenCalledWith('::set-output name=error_message::');
});
it('应该在失败时设置正确的 Action 输出', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock HTTP 请求返回错误
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.error.invalidToken });
// Mock GitHub Actions 输出
const outputSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
// 验证 Action 输出
expect(outputSpy).toHaveBeenCalledWith('::set-output name=success::false');
expect(outputSpy).toHaveBeenCalledWith('::set-output name=message_id::');
expect(outputSpy).toHaveBeenCalledWith(
expect.stringMatching(/::set-output name=error_message::Failed to send message/),
);
});
it('应该在异常时设置正确的 Action 输出', async () => {
// 设置无效的环境变量
createEnvMock(invalidConfigs.envVars.missingWebhook);
// Mock GitHub Actions 输出 (使用 console.log 而不是 process.stdout.write)
const outputSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(false);
// 验证 Action 输出
expect(outputSpy).toHaveBeenCalledWith('::set-output name=success::false');
expect(outputSpy).toHaveBeenCalledWith('::set-output name=message_id::');
expect(outputSpy).toHaveBeenCalledWith(
expect.stringMatching(/::set-output name=error_message::Action execution failed/),
);
});
});
describe('性能测试', () => {
it('应该在合理时间内完成执行', async () => {
// 设置环境变量
createEnvMock(validConfigs.envVars.text);
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// 记录开始时间
const startTime = Date.now();
// 执行主函数
const result = await main();
// 计算执行时间
const executionTime = Date.now() - startTime;
// 验证结果
expect(result.success).toBe(true);
expect(executionTime).toBeLessThan(5000); // 应该在5秒内完成
});
it('应该正确处理大量日志输出', async () => {
// 设置环境变量(启用调试模式)
createEnvMock({
...validConfigs.envVars.text,
DEBUG: 'true',
});
// Mock HTTP 请求
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
// 执行主函数
const result = await main();
// 验证结果
expect(result.success).toBe(true);
// 验证日志调用次数
expect(consoleSpy.log.mock.calls.length).toBeGreaterThan(10);
});
});
});

View File

@@ -0,0 +1,44 @@
/**
* 全局测试设置文件
* 在所有测试运行前执行的初始化代码
*/
import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest';
// 全局变量存储原始环境变量
let originalEnv = {};
beforeAll(() => {
// 保存原始环境变量
originalEnv = { ...process.env };
// 设置测试环境标识
process.env.NODE_ENV = 'test';
// 禁用网络请求(可选)
process.env.DISABLE_NETWORK = 'true';
});
afterAll(() => {
// 恢复原始环境变量
process.env = originalEnv;
});
beforeEach(() => {
// 每个测试前的清理工作
// 清理可能的环境变量污染
});
afterEach(() => {
// 每个测试后的清理工作
// 重置模块状态等
});
// 全局错误处理
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', error => {
console.error('Uncaught Exception:', error);
});

592
tests/unit/config.test.js Normal file
View 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
View 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
View 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
View 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();
});
});
});