feat: 初始化 Gitea Action 发送钉钉机器人消息项目
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{yaml, yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
149
.gitignore
vendored
Normal file
149
.gitignore
vendored
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Sveltekit cache directory
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Firebase cache directory
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Vite files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.cursor
|
||||||
|
.trae
|
||||||
3
.husky/pre-commit
Normal file
3
.husky/pre-commit
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 运行代码检查
|
||||||
|
pnpm run lint
|
||||||
|
pnpm run format
|
||||||
11
.prettierrc.json
Normal file
11
.prettierrc.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
218
README.md
Normal file
218
README.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# 钉钉消息推送 Gitea Action
|
||||||
|
|
||||||
|
一个用于向钉钉群发送消息的 Gitea Action,支持文本、Markdown、链接、ActionCard 和 FeedCard 消息类型。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 获取钉钉 Webhook URL
|
||||||
|
|
||||||
|
1. 在钉钉群中添加"自定义机器人"
|
||||||
|
2. 复制生成的 Webhook URL
|
||||||
|
3. 配置安全设置
|
||||||
|
|
||||||
|
### 2. 在 Gitea 仓库中使用
|
||||||
|
|
||||||
|
创建 `.gitea/workflows/dingtalk-notify.yml` 文件:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: DingTalk Notification
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Send DingTalk Message
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'text'
|
||||||
|
content: '🎉 代码已推送到 ${{ github.repository }} 仓库!'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输入参数
|
||||||
|
|
||||||
|
| 参数名 | 必需 | 默认值 | 描述 |
|
||||||
|
| ----------------- | ---- | ------- | -------------------------------------------------------------------- |
|
||||||
|
| `webhook_url` | 是 | - | 钉钉机器人 Webhook URL |
|
||||||
|
| `message_type` | 否 | `text` | 消息类型:`text`、`markdown`、`link`、`actionCard`、`feedCard` |
|
||||||
|
| `content` | 是 | - | 消息内容 |
|
||||||
|
| `title` | 否 | - | 消息标题(markdown 和 link 类型必需) |
|
||||||
|
| `at_mobiles` | 否 | - | @的手机号,多个用逗号分隔 |
|
||||||
|
| `at_all` | 否 | `false` | 是否@所有人 |
|
||||||
|
| `link_url` | 否 | - | 链接地址(link 类型必需) |
|
||||||
|
| `pic_url` | 否 | - | 图片地址(link 类型可选) |
|
||||||
|
| `btn_orientation` | 否 | `0` | actionCard 按钮排列方向:`0`竖直、`1`水平 |
|
||||||
|
| `single_title` | 否 | - | actionCard 单按钮标题(整体跳转) |
|
||||||
|
| `single_url` | 否 | - | actionCard 单按钮链接(整体跳转) |
|
||||||
|
| `buttons` | 否 | - | actionCard 多按钮配置(JSON 数组),每项 `{ title, actionURL }` |
|
||||||
|
| `feed_links` | 否 | - | feedCard 链接列表(JSON 数组),每项 `{ title, messageURL, picURL }` |
|
||||||
|
|
||||||
|
## 输出参数
|
||||||
|
|
||||||
|
| 参数名 | 描述 |
|
||||||
|
| --------------- | -------------------- |
|
||||||
|
| `success` | 是否发送成功 |
|
||||||
|
| `message_id` | 消息ID(如果可用) |
|
||||||
|
| `error_message` | 错误信息(如果失败) |
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 文本消息
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Send Text Message
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'text'
|
||||||
|
content: '✅ 构建成功!提交者:${{ github.actor }}'
|
||||||
|
at_mobiles: '13800138000,13900139000'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markdown 消息
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Send Markdown Message
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'markdown'
|
||||||
|
title: '构建报告'
|
||||||
|
content: |
|
||||||
|
## 🎉 构建成功
|
||||||
|
|
||||||
|
**仓库**: ${{ github.repository }}
|
||||||
|
**分支**: ${{ github.ref_name }}
|
||||||
|
**提交者**: ${{ github.actor }}
|
||||||
|
**提交信息**: ${{ github.event.head_commit.message }}
|
||||||
|
|
||||||
|
[查看详情](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
|
at_all: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 链接消息
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Send Link Message
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'link'
|
||||||
|
title: '新版本发布'
|
||||||
|
content: '项目已发布新版本,点击查看详情'
|
||||||
|
link_url: 'https://github.com/${{ github.repository }}/releases/latest'
|
||||||
|
pic_url: 'https://avatars.githubusercontent.com/u/${{ github.actor_id }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### ActionCard(整体跳转单按钮)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Send ActionCard Single Button
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'actionCard'
|
||||||
|
title: '乔布斯 20 年'
|
||||||
|
content: |
|
||||||
|

|
||||||
|
|
||||||
|
乔布斯 20 年
|
||||||
|
btn_orientation: '0'
|
||||||
|
single_title: '阅读全文'
|
||||||
|
single_url: 'https://www.dingtalk.com/'
|
||||||
|
```
|
||||||
|
|
||||||
|
### ActionCard(独立跳转多按钮)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Send ActionCard Multiple Buttons
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'actionCard'
|
||||||
|
title: '构建结果'
|
||||||
|
content: |
|
||||||
|
## 本次构建完成
|
||||||
|
- 版本:${{ github.ref_name }}
|
||||||
|
- 提交者:${{ github.actor }}
|
||||||
|
btn_orientation: '1'
|
||||||
|
buttons: >-
|
||||||
|
[
|
||||||
|
{ "title": "查看日志", "actionURL": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" },
|
||||||
|
{ "title": "查看发布页", "actionURL": "https://github.com/${{ github.repository }}/releases/latest" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### FeedCard(图文链接)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Send FeedCard
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'feedCard'
|
||||||
|
feed_links: >-
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "时代的火车向前开",
|
||||||
|
"messageURL": "https://www.dingtalk.com/",
|
||||||
|
"picURL": "https://static.dingtalk.com/media/xxx1.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "正当防卫体育大战",
|
||||||
|
"messageURL": "https://www.dingtalk.com/",
|
||||||
|
"picURL": "https://static.dingtalk.com/media/xxx2.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 高级配置
|
||||||
|
|
||||||
|
### 条件发送
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 构建失败时发送通知
|
||||||
|
- name: Send Notification on Failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'text'
|
||||||
|
content: '❌ 构建失败!请检查代码。'
|
||||||
|
at_all: true
|
||||||
|
|
||||||
|
# 构建成功时发送通知
|
||||||
|
- name: Send Notification on Success
|
||||||
|
if: success()
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }}
|
||||||
|
message_type: 'markdown'
|
||||||
|
title: '✅ 构建成功'
|
||||||
|
content: |
|
||||||
|
## 构建完成
|
||||||
|
|
||||||
|
**项目**: ${{ github.repository }}
|
||||||
|
**分支**: ${{ github.ref_name }}
|
||||||
|
**提交者**: ${{ github.actor }}
|
||||||
|
**提交信息**: ${{ github.event.head_commit.message }}
|
||||||
|
|
||||||
|
[查看详情](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
|
at_mobiles: ['${{ secrets.NOTIFY_MOBILE }}']
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多环境配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Send to Different Groups
|
||||||
|
uses: actions/dingtalk-bot@v0.1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ github.ref == 'refs/heads/main' && secrets.PROD_DINGTALK_WEBHOOK || secrets.DEV_DINGTALK_WEBHOOK }}
|
||||||
|
message_type: 'text'
|
||||||
|
content: '部署到 ${{ github.ref == "refs/heads/main" && "生产环境" || "测试环境" }} 完成'
|
||||||
|
```
|
||||||
64
action.yml
Normal file
64
action.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
name: 'DingTalk Notification'
|
||||||
|
description: 'Send notifications to DingTalk Bot via webhook'
|
||||||
|
author: 'DingTalk Action Team'
|
||||||
|
branding:
|
||||||
|
icon: 'message-circle'
|
||||||
|
color: 'blue'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
webhook_url:
|
||||||
|
description: 'DingTalk webhook URL'
|
||||||
|
required: true
|
||||||
|
message_type:
|
||||||
|
description: 'Message type (text, markdown, link, actionCard, feedCard)'
|
||||||
|
required: false
|
||||||
|
default: 'text'
|
||||||
|
content:
|
||||||
|
description: 'Message content'
|
||||||
|
required: true
|
||||||
|
title:
|
||||||
|
description: 'Message title (required for markdown and link types)'
|
||||||
|
required: false
|
||||||
|
at_mobiles:
|
||||||
|
description: 'Mobile numbers to mention (comma separated)'
|
||||||
|
required: false
|
||||||
|
at_all:
|
||||||
|
description: 'Whether to mention all members'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
link_url:
|
||||||
|
description: 'Link URL (required for link type messages)'
|
||||||
|
required: false
|
||||||
|
pic_url:
|
||||||
|
description: 'Picture URL (optional for link type messages)'
|
||||||
|
required: false
|
||||||
|
# ActionCard inputs
|
||||||
|
btn_orientation:
|
||||||
|
description: "Button orientation for actionCard ('0' vertical, '1' horizontal)"
|
||||||
|
required: false
|
||||||
|
default: '0'
|
||||||
|
single_title:
|
||||||
|
description: 'Single button title for actionCard (overall jump)'
|
||||||
|
required: false
|
||||||
|
single_url:
|
||||||
|
description: 'Single button URL for actionCard (overall jump)'
|
||||||
|
required: false
|
||||||
|
buttons:
|
||||||
|
description: 'ActionCard multiple buttons JSON array: [{title, actionURL}]'
|
||||||
|
required: false
|
||||||
|
# FeedCard inputs
|
||||||
|
feed_links:
|
||||||
|
description: 'FeedCard links JSON array: [{title, messageURL, picURL}]'
|
||||||
|
required: false
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
success:
|
||||||
|
description: 'Whether the message was sent successfully'
|
||||||
|
message_id:
|
||||||
|
description: 'DingTalk message ID if available'
|
||||||
|
error_message:
|
||||||
|
description: 'Error message if sending failed'
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: 'node20'
|
||||||
|
main: 'src/index.js'
|
||||||
71
eslint.config.js
Normal file
71
eslint.config.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import jsdoc from 'eslint-plugin-jsdoc';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
jsdoc.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
Buffer: 'readonly',
|
||||||
|
__dirname: 'readonly',
|
||||||
|
__filename: 'readonly',
|
||||||
|
global: 'readonly',
|
||||||
|
module: 'readonly',
|
||||||
|
require: 'readonly',
|
||||||
|
exports: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
clearInterval: 'readonly',
|
||||||
|
setImmediate: 'readonly',
|
||||||
|
clearImmediate: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
AbortController: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
jsdoc,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'jsdoc/require-description': 'warn',
|
||||||
|
'jsdoc/require-description-complete-sentence': 'off',
|
||||||
|
'jsdoc/require-param-description': 'warn',
|
||||||
|
'jsdoc/require-returns-description': 'warn',
|
||||||
|
'jsdoc/check-param-names': 'error',
|
||||||
|
'jsdoc/check-tag-names': 'error',
|
||||||
|
'jsdoc/check-types': 'error',
|
||||||
|
'jsdoc/no-undefined-types': 'warn',
|
||||||
|
'jsdoc/valid-types': 'error',
|
||||||
|
'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }],
|
||||||
|
'no-console': 'off',
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'no-var': 'error',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'prefer-arrow-callback': 'error',
|
||||||
|
'arrow-spacing': 'error',
|
||||||
|
'object-shorthand': 'error',
|
||||||
|
'prefer-template': 'error',
|
||||||
|
semi: ['error', 'always'],
|
||||||
|
quotes: ['error', 'single'],
|
||||||
|
'comma-dangle': ['error', 'always-multiline'],
|
||||||
|
indent: ['error', 2, { SwitchCase: 0 }],
|
||||||
|
'max-len': ['warn', { code: 100, ignoreUrls: true }],
|
||||||
|
'no-throw-literal': 'error',
|
||||||
|
'prefer-promise-reject-errors': 'error',
|
||||||
|
'no-eval': 'error',
|
||||||
|
'no-implied-eval': 'error',
|
||||||
|
'no-new-func': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.js', 'tests/**/*.js'],
|
||||||
|
},
|
||||||
|
prettier,
|
||||||
|
];
|
||||||
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "dingtalk-bot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A Gitea Action for sending notifications to DingTalk",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint src/**/*.js",
|
||||||
|
"lint:fix": "eslint src/**/*.js --fix",
|
||||||
|
"format": "prettier --write src/**/*.js",
|
||||||
|
"format:check": "prettier --check src/**/*.js",
|
||||||
|
"check": "pnpm run lint && pnpm run format:check",
|
||||||
|
"pre-commit": "pnpm run lint && pnpm run format",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:unit": "vitest tests/unit",
|
||||||
|
"test:integration": "vitest tests/integration",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:watch": "vitest --watch",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"gitea",
|
||||||
|
"action",
|
||||||
|
"dingtalk",
|
||||||
|
"notification"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.37.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"eslint": "^9.37.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-jsdoc": "^50.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
2124
pnpm-lock.yaml
generated
Normal file
2124
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
345
src/config.js
Normal file
345
src/config.js
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* 配置解析模块
|
||||||
|
* 处理 Action 输入参数和环境变量的解析与验证
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支持的消息类型枚举
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export const MESSAGE_TYPES = {
|
||||||
|
TEXT: 'text',
|
||||||
|
MARKDOWN: 'markdown',
|
||||||
|
LINK: 'link',
|
||||||
|
ACTION_CARD: 'actionCard',
|
||||||
|
FEED_CARD: 'feedCard',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置对象类型定义
|
||||||
|
*
|
||||||
|
* @typedef {object} ActionConfig
|
||||||
|
* @property {string} webhookUrl - 钉钉 Webhook URL
|
||||||
|
* @property {string} messageType - 消息类型
|
||||||
|
* @property {string} content - 消息内容
|
||||||
|
* @property {string} [title] - 消息标题
|
||||||
|
* @property {string[]} [atMobiles] - @的手机号列表
|
||||||
|
* @property {boolean} [atAll] - 是否@所有人
|
||||||
|
* @property {string} [linkUrl] - 链接地址
|
||||||
|
* @property {string} [picUrl] - 图片地址
|
||||||
|
* @property {string} [btnOrientation] - actionCard 按钮排列方向('0'竖直,'1'水平)
|
||||||
|
* @property {string} [singleTitle] - actionCard 单按钮标题(整体跳转)
|
||||||
|
* @property {string} [singleURL] - actionCard 单按钮跳转链接(整体跳转)
|
||||||
|
* @property {{ title: string, actionURL: string }[]} [buttons] - actionCard 多按钮配置(独立跳转)
|
||||||
|
* @property {{ title: string, messageURL: string, picURL: string }[]} [feedLinks] - feedCard 链接列表
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从环境变量中获取 Action 输入参数
|
||||||
|
*
|
||||||
|
* @returns {object} 原始输入参数对象
|
||||||
|
*/
|
||||||
|
function getActionInputs() {
|
||||||
|
return {
|
||||||
|
webhook_url: process.env.INPUT_WEBHOOK_URL || '',
|
||||||
|
message_type: process.env.INPUT_MESSAGE_TYPE || MESSAGE_TYPES.TEXT,
|
||||||
|
content: process.env.INPUT_CONTENT || '',
|
||||||
|
title: process.env.INPUT_TITLE || '',
|
||||||
|
at_mobiles: process.env.INPUT_AT_MOBILES || '',
|
||||||
|
at_all: process.env.INPUT_AT_ALL || 'false',
|
||||||
|
link_url: process.env.INPUT_LINK_URL || '',
|
||||||
|
pic_url: process.env.INPUT_PIC_URL || '',
|
||||||
|
// actionCard 相关
|
||||||
|
btn_orientation: process.env.INPUT_BTN_ORIENTATION || '0',
|
||||||
|
single_title: process.env.INPUT_SINGLE_TITLE || '',
|
||||||
|
single_url: process.env.INPUT_SINGLE_URL || '',
|
||||||
|
buttons: process.env.INPUT_BUTTONS || '',
|
||||||
|
// feedCard 相关
|
||||||
|
feed_links: process.env.INPUT_FEED_LINKS || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证钉钉 Webhook URL 格式
|
||||||
|
*
|
||||||
|
* @param {string} webhookUrl - 钉钉 Webhook URL
|
||||||
|
* @returns {boolean} 验证结果
|
||||||
|
*/
|
||||||
|
export function validateWebhookUrl(webhookUrl) {
|
||||||
|
if (!webhookUrl || typeof webhookUrl !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 钉钉 Webhook URL 格式验证
|
||||||
|
const dingtalkUrlPattern = /^https:\/\/oapi\.dingtalk\.com\/robot\/send\?access_token=[\w-]+$/;
|
||||||
|
return dingtalkUrlPattern.test(webhookUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证消息类型
|
||||||
|
*
|
||||||
|
* @param {string} messageType - 消息类型
|
||||||
|
* @returns {boolean} 验证结果
|
||||||
|
*/
|
||||||
|
export function validateMessageType(messageType) {
|
||||||
|
return Object.values(MESSAGE_TYPES).includes(messageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析手机号列表
|
||||||
|
*
|
||||||
|
* @param {string} atMobilesStr - 逗号分隔的手机号字符串
|
||||||
|
* @returns {string[]} 手机号数组
|
||||||
|
*/
|
||||||
|
function parseAtMobiles(atMobilesStr) {
|
||||||
|
if (!atMobilesStr || typeof atMobilesStr !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return atMobilesStr
|
||||||
|
.split(',')
|
||||||
|
.map(mobile => mobile.trim())
|
||||||
|
.filter(mobile => mobile.length > 0)
|
||||||
|
.filter(mobile => /^1[3-9]\d{9}$/.test(mobile)); // 简单的手机号验证
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 actionCard 多按钮配置(JSON 数组)
|
||||||
|
*
|
||||||
|
* @param {string} buttonsStr - JSON 字符串,形如 [{"title":"阅读全文","actionURL":"https://..."}]
|
||||||
|
* @returns {{ title: string, actionURL: string }[]} 按钮数组
|
||||||
|
*/
|
||||||
|
function parseActionCardButtons(buttonsStr) {
|
||||||
|
if (!buttonsStr || typeof buttonsStr !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(buttonsStr);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter(btn => btn && typeof btn === 'object')
|
||||||
|
.map(btn => ({ title: String(btn.title || ''), actionURL: String(btn.actionURL || '') }))
|
||||||
|
.filter(btn => btn.title.length > 0 && /^https?:\/\/.+/.test(btn.actionURL));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 feedCard 链接配置(JSON 数组)
|
||||||
|
*
|
||||||
|
* @param {string} linksStr - JSON 字符串,形如 [{"title":"...","messageURL":"https://...","picURL":"https://..."}]
|
||||||
|
* @returns {{ title: string, messageURL: string, picURL: string }[]} 链接数组
|
||||||
|
*/
|
||||||
|
function parseFeedLinks(linksStr) {
|
||||||
|
if (!linksStr || typeof linksStr !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(linksStr);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter(link => link && typeof link === 'object')
|
||||||
|
.map(link => ({
|
||||||
|
title: String(link.title || ''),
|
||||||
|
messageURL: String(link.messageURL || ''),
|
||||||
|
picURL: String(link.picURL || ''),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
link =>
|
||||||
|
link.title.length > 0 &&
|
||||||
|
/^https?:\/\/.+/.test(link.messageURL) &&
|
||||||
|
/^https?:\/\/.+/.test(link.picURL),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证配置参数的完整性
|
||||||
|
*
|
||||||
|
* @param {ActionConfig} config - 配置对象
|
||||||
|
* @returns {object} 验证结果
|
||||||
|
*/
|
||||||
|
function validateConfig(config) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
errors.push('webhook_url is required');
|
||||||
|
} else if (!validateWebhookUrl(config.webhookUrl)) {
|
||||||
|
errors.push('webhook_url format is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedCard 消息类型不需要 content 字段
|
||||||
|
if (!config.content && config.messageType !== MESSAGE_TYPES.FEED_CARD) {
|
||||||
|
errors.push('content is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateMessageType(config.messageType)) {
|
||||||
|
errors.push(`message_type must be one of: ${Object.values(MESSAGE_TYPES).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证特定消息类型的参数
|
||||||
|
if (config.messageType === MESSAGE_TYPES.MARKDOWN && !config.title) {
|
||||||
|
errors.push('title is required for markdown message type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.messageType === MESSAGE_TYPES.LINK) {
|
||||||
|
if (!config.title) {
|
||||||
|
errors.push('title is required for link message type');
|
||||||
|
}
|
||||||
|
if (!config.linkUrl) {
|
||||||
|
errors.push('link_url is required for link message type');
|
||||||
|
} else if (!/^https?:\/\/.+/.test(config.linkUrl)) {
|
||||||
|
errors.push('link_url must be a valid HTTP/HTTPS URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionCard 校验
|
||||||
|
if (config.messageType === MESSAGE_TYPES.ACTION_CARD) {
|
||||||
|
if (!config.title) {
|
||||||
|
errors.push('title is required for actionCard message type');
|
||||||
|
}
|
||||||
|
if (!config.content) {
|
||||||
|
errors.push('content is required for actionCard message type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// btnOrientation 必须为 '0' 或 '1'
|
||||||
|
if (config.btnOrientation !== '0' && config.btnOrientation !== '1') {
|
||||||
|
errors.push("btn_orientation must be '0' or '1'");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSingle = !!config.singleTitle || !!config.singleURL;
|
||||||
|
const hasButtons = Array.isArray(config.buttons) && config.buttons.length > 0;
|
||||||
|
|
||||||
|
if (!hasSingle && !hasButtons) {
|
||||||
|
errors.push('actionCard requires either single_title/single_url or buttons');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSingle) {
|
||||||
|
if (!config.singleTitle) {
|
||||||
|
errors.push('single_title is required when using single button');
|
||||||
|
}
|
||||||
|
if (!config.singleURL) {
|
||||||
|
errors.push('single_url is required when using single button');
|
||||||
|
} else if (!/^https?:\/\/.+/.test(config.singleURL)) {
|
||||||
|
errors.push('single_url must be a valid HTTP/HTTPS URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasButtons) {
|
||||||
|
for (const btn of config.buttons) {
|
||||||
|
if (!btn.title || !btn.actionURL) {
|
||||||
|
errors.push('each button must have title and actionURL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\/.+/.test(btn.actionURL)) {
|
||||||
|
errors.push('button.actionURL must be a valid HTTP/HTTPS URL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedCard 校验
|
||||||
|
if (config.messageType === MESSAGE_TYPES.FEED_CARD) {
|
||||||
|
const links = config.feedLinks || [];
|
||||||
|
if (!Array.isArray(links) || links.length === 0) {
|
||||||
|
errors.push('feed_links is required and must be a non-empty array for feedCard');
|
||||||
|
} else {
|
||||||
|
for (const link of links) {
|
||||||
|
if (!link.title || !link.messageURL || !link.picURL) {
|
||||||
|
errors.push('each feed link must have title, messageURL and picURL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\/.+/.test(link.messageURL) || !/^https?:\/\/.+/.test(link.picURL)) {
|
||||||
|
errors.push('feed link URLs must be valid HTTP/HTTPS');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析和验证 Action 输入参数
|
||||||
|
*
|
||||||
|
* @returns {Promise<ActionConfig>} 解析后的配置对象
|
||||||
|
* @throws {Error} 当配置验证失败时抛出错误
|
||||||
|
*/
|
||||||
|
export async function parseConfig() {
|
||||||
|
const inputs = getActionInputs();
|
||||||
|
|
||||||
|
// 构建配置对象
|
||||||
|
const config = {
|
||||||
|
webhookUrl: inputs.webhook_url,
|
||||||
|
messageType: inputs.message_type,
|
||||||
|
content: inputs.content,
|
||||||
|
title: inputs.title || undefined,
|
||||||
|
atMobiles: parseAtMobiles(inputs.at_mobiles),
|
||||||
|
atAll: inputs.at_all.toLowerCase() === 'true',
|
||||||
|
linkUrl: inputs.link_url || undefined,
|
||||||
|
picUrl: inputs.pic_url || undefined,
|
||||||
|
// actionCard
|
||||||
|
btnOrientation: (inputs.btn_orientation || '0').trim(),
|
||||||
|
singleTitle: inputs.single_title || undefined,
|
||||||
|
singleURL: inputs.single_url || undefined,
|
||||||
|
buttons: parseActionCardButtons(inputs.buttons),
|
||||||
|
// feedCard
|
||||||
|
feedLinks: parseFeedLinks(inputs.feed_links),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
const validation = validateConfig(config);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
const errorMessage = `Configuration validation failed:\n${validation.errors.join('\n')}`;
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出配置信息到控制台(用于调试)
|
||||||
|
*
|
||||||
|
* @param {ActionConfig} config - 配置对象
|
||||||
|
*/
|
||||||
|
export function logConfig(config) {
|
||||||
|
console.log('=== Action Configuration ===');
|
||||||
|
console.log(`Message Type: ${config.messageType}`);
|
||||||
|
console.log(`Content Length: ${config.content.length} characters`);
|
||||||
|
|
||||||
|
if (config.title) {
|
||||||
|
console.log(`Title: ${config.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.atMobiles && config.atMobiles.length > 0) {
|
||||||
|
console.log(`At Mobiles: ${config.atMobiles.length} numbers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.atAll) {
|
||||||
|
console.log('At All: true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.linkUrl) {
|
||||||
|
console.log(`Link URL: ${config.linkUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.picUrl) {
|
||||||
|
console.log(`Picture URL: ${config.picUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Webhook URL: [HIDDEN FOR SECURITY]');
|
||||||
|
console.log('=============================');
|
||||||
|
}
|
||||||
227
src/http.js
Normal file
227
src/http.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* HTTP 客户端模块
|
||||||
|
* 使用 Node.js 内置 fetch API 发送 HTTP 请求到钉钉 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 响应结果
|
||||||
|
*
|
||||||
|
* @typedef {object} HttpResponse
|
||||||
|
* @property {boolean} success - 请求是否成功
|
||||||
|
* @property {number} statusCode - HTTP 状态码
|
||||||
|
* @property {object} data - 响应数据
|
||||||
|
* @property {string} [error] - 错误信息
|
||||||
|
* @property {number} duration - 请求耗时(毫秒)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉 API 响应格式
|
||||||
|
*
|
||||||
|
* @typedef {object} DingTalkResponse
|
||||||
|
* @property {number} errcode - 错误码,0表示成功
|
||||||
|
* @property {string} errmsg - 错误信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送单次 HTTP POST 请求
|
||||||
|
*
|
||||||
|
* @param {string} url - 请求URL
|
||||||
|
* @param {object} data - 请求数据
|
||||||
|
* @returns {Promise<HttpResponse>} HTTP响应结果
|
||||||
|
*/
|
||||||
|
async function sendSingleRequest(url, data) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'DingTalk-Bot/1.0.0',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const responseText = await response.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试解析 JSON 响应
|
||||||
|
const responseData = responseText ? JSON.parse(responseText) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.status === 200 && responseData.errcode === 0,
|
||||||
|
statusCode: response.status,
|
||||||
|
data: responseData,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
// JSON 解析失败,返回空对象作为 data(保持与原始实现一致)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
statusCode: response.status,
|
||||||
|
data: {},
|
||||||
|
error: `JSON parse error: ${parseError.message}`,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 网络错误或其他错误
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
statusCode: 0,
|
||||||
|
data: { errcode: -1, errmsg: 'Network error' },
|
||||||
|
error: error.message,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 POST 请求到钉钉 Webhook
|
||||||
|
*
|
||||||
|
* @param {string} url - 钉钉 Webhook URL
|
||||||
|
* @param {object} data - 消息数据
|
||||||
|
* @returns {Promise<HttpResponse>} HTTP响应结果
|
||||||
|
*/
|
||||||
|
export async function sendRequest(url, data) {
|
||||||
|
console.log('Sending request to DingTalk webhook...');
|
||||||
|
console.log(`URL: ${url.replace(/access_token=[\w-]+/, 'access_token=***')}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendSingleRequest(url, data);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Request completed - Status: ${response.statusCode}, Duration: ${response.duration}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
console.log('✅ Message sent successfully!');
|
||||||
|
} else {
|
||||||
|
// 记录失败信息
|
||||||
|
if (response.data && typeof response.data === 'object' && response.data.errmsg) {
|
||||||
|
console.log(
|
||||||
|
`❌ DingTalk API error: ${response.data.errmsg} (code: ${response.data.errcode})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
console.log(`❌ Request error: ${response.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Unexpected error: ${error.message}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
statusCode: 0,
|
||||||
|
data: { errcode: -1, errmsg: 'Unexpected error' },
|
||||||
|
error: error.message,
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证钉钉 API 响应
|
||||||
|
*
|
||||||
|
* @param {HttpResponse} response - HTTP响应结果
|
||||||
|
* @returns {object} 验证结果
|
||||||
|
*/
|
||||||
|
export function validateResponse(response) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
errors.push('Response is null or undefined');
|
||||||
|
return { isValid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.success !== 'boolean') {
|
||||||
|
errors.push('success must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.statusCode !== 'number') {
|
||||||
|
errors.push('statusCode must be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data) {
|
||||||
|
errors.push('Missing required field: data');
|
||||||
|
} else if (typeof response.data === 'object') {
|
||||||
|
// 只有当 data 是对象时才检查 errcode 和 errmsg
|
||||||
|
if (!('errcode' in response.data)) {
|
||||||
|
errors.push('Missing required field: errcode');
|
||||||
|
} else if (typeof response.data.errcode !== 'number') {
|
||||||
|
errors.push('errcode must be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('errmsg' in response.data)) {
|
||||||
|
errors.push('Missing required field: errmsg');
|
||||||
|
} else if (typeof response.data.errmsg !== 'string') {
|
||||||
|
errors.push('errmsg must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取钉钉错误码的描述信息
|
||||||
|
*
|
||||||
|
* @param {number} errcode - 钉钉错误码
|
||||||
|
* @returns {string} 错误描述
|
||||||
|
*/
|
||||||
|
export function getDingTalkErrorDescription(errcode) {
|
||||||
|
const errorMap = {
|
||||||
|
0: '请求成功',
|
||||||
|
'-1': '系统繁忙,请稍后再试',
|
||||||
|
310000: '无效的 webhook URL 或访问令牌',
|
||||||
|
310001: '无效签名',
|
||||||
|
310002: '无效时间戳',
|
||||||
|
310003: '无效请求格式',
|
||||||
|
310004: '消息内容过长',
|
||||||
|
310005: '消息发送频率超限',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理非数字输入
|
||||||
|
if (typeof errcode !== 'number' && typeof errcode !== 'string') {
|
||||||
|
return '未知错误';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorMap[errcode] || '未知错误';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建请求摘要信息
|
||||||
|
*
|
||||||
|
* @param {HttpResponse} response - HTTP响应结果
|
||||||
|
* @returns {string} 请求摘要
|
||||||
|
*/
|
||||||
|
export function createRequestSummary(response) {
|
||||||
|
if (!response) {
|
||||||
|
return 'No response data available';
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = `Status: ${response.statusCode}, Duration: ${response.duration}ms`;
|
||||||
|
|
||||||
|
if (response.data && typeof response.data === 'object') {
|
||||||
|
summary += `, DingTalk Code: ${response.data.errcode}`;
|
||||||
|
|
||||||
|
if (response.data.errcode !== 0) {
|
||||||
|
const description = getDingTalkErrorDescription(response.data.errcode);
|
||||||
|
summary += ` (${description})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
summary += `, Error: ${response.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
246
src/index.js
Normal file
246
src/index.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉消息推送 Gitea Action 主入口文件
|
||||||
|
* 整合所有模块,处理主要逻辑流程
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseConfig, logConfig } from './config.js';
|
||||||
|
import { buildMessage, validateMessage, getMessageSummary } from './message.js';
|
||||||
|
import {
|
||||||
|
sendRequest,
|
||||||
|
validateResponse,
|
||||||
|
createRequestSummary,
|
||||||
|
getDingTalkErrorDescription,
|
||||||
|
} from './http.js';
|
||||||
|
import {
|
||||||
|
logInfo,
|
||||||
|
logError,
|
||||||
|
logDebug,
|
||||||
|
logWarn,
|
||||||
|
setActionOutput,
|
||||||
|
setActionFailed,
|
||||||
|
isDebugMode,
|
||||||
|
formatDuration,
|
||||||
|
getTimestamp,
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action 执行结果
|
||||||
|
*
|
||||||
|
* @typedef {object} ActionResult
|
||||||
|
* @property {boolean} success - 是否成功
|
||||||
|
* @property {string} [messageId] - 消息ID
|
||||||
|
* @property {string} [error] - 错误信息
|
||||||
|
* @property {object} [details] - 详细信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主执行函数
|
||||||
|
*
|
||||||
|
* @returns {Promise<ActionResult>} 执行结果
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logInfo('🚀 DingTalk Gitea Action started');
|
||||||
|
logInfo(`Timestamp: ${getTimestamp()}`);
|
||||||
|
logInfo(`Debug mode: ${isDebugMode()}`);
|
||||||
|
|
||||||
|
// 步骤 1: 解析和验证配置
|
||||||
|
logInfo('📋 Step 1: Parsing configuration...');
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
if (isDebugMode()) {
|
||||||
|
logConfig(config);
|
||||||
|
} else {
|
||||||
|
logInfo(`Configuration loaded - Message type: ${config.messageType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤 2: 构建消息
|
||||||
|
logInfo('📝 Step 2: Building message...');
|
||||||
|
const message = buildMessage(config);
|
||||||
|
|
||||||
|
// 验证消息格式
|
||||||
|
const messageValidation = validateMessage(message);
|
||||||
|
if (!messageValidation.isValid) {
|
||||||
|
throw new Error(`Message validation failed: ${messageValidation.errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageSummary = getMessageSummary(message);
|
||||||
|
logInfo(`Message built successfully - ${messageSummary}`);
|
||||||
|
|
||||||
|
if (isDebugMode()) {
|
||||||
|
logDebug('Message payload:', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤 3: 发送消息
|
||||||
|
logInfo('📤 Step 3: Sending message to DingTalk...');
|
||||||
|
const response = await sendRequest(config.webhookUrl, message);
|
||||||
|
|
||||||
|
// 验证响应
|
||||||
|
const responseValidation = validateResponse(response);
|
||||||
|
if (!responseValidation.isValid) {
|
||||||
|
logWarn(`Response validation failed: ${responseValidation.errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSummary = createRequestSummary(response);
|
||||||
|
logInfo(`Request completed - ${requestSummary}`);
|
||||||
|
|
||||||
|
if (isDebugMode()) {
|
||||||
|
logDebug('Full response:', response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤 4: 处理结果
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logInfo(`⏱️ Total execution time: ${formatDuration(duration)}`);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
logInfo('✅ Message sent successfully!');
|
||||||
|
|
||||||
|
// 设置 Action 输出
|
||||||
|
setActionOutput('success', 'true');
|
||||||
|
setActionOutput('message_id', response.data.message_id || '');
|
||||||
|
setActionOutput('error_message', '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: response.data.message_id,
|
||||||
|
details: {
|
||||||
|
duration,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
messageSummary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 发送失败
|
||||||
|
const errorDescription = getDingTalkErrorDescription(response.data.errcode);
|
||||||
|
const errorMessage = `Failed to send message: ${errorDescription}`;
|
||||||
|
|
||||||
|
logError(errorMessage);
|
||||||
|
logError(`DingTalk error code: ${response.data.errcode}`);
|
||||||
|
logError(`DingTalk error message: ${response.data.errmsg}`);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
logError(`Network error: ${response.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 Action 输出
|
||||||
|
setActionOutput('success', 'false');
|
||||||
|
setActionOutput('message_id', '');
|
||||||
|
setActionOutput('error_message', errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
details: {
|
||||||
|
duration,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
errcode: response.data.errcode,
|
||||||
|
errmsg: response.data.errmsg,
|
||||||
|
networkError: response.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const errorMessage = `Action execution failed: ${error.message}`;
|
||||||
|
|
||||||
|
logError(errorMessage);
|
||||||
|
logError(`Error details: ${error.stack}`);
|
||||||
|
logError(`Execution time before error: ${formatDuration(duration)}`);
|
||||||
|
|
||||||
|
// 设置 Action 输出
|
||||||
|
setActionOutput('success', 'false');
|
||||||
|
setActionOutput('message_id', '');
|
||||||
|
setActionOutput('error_message', errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
details: {
|
||||||
|
duration,
|
||||||
|
errorType: error.constructor.name,
|
||||||
|
errorCode: error.code,
|
||||||
|
errorDetails: error.details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理未捕获的异常
|
||||||
|
*
|
||||||
|
* @param {Error} error - 错误对象
|
||||||
|
*/
|
||||||
|
function handleUncaughtException(error) {
|
||||||
|
logError('💥 Uncaught exception occurred!');
|
||||||
|
logError(`Error: ${error.message}`);
|
||||||
|
logError(`Stack: ${error.stack}`);
|
||||||
|
|
||||||
|
setActionOutput('success', 'false');
|
||||||
|
setActionOutput('message_id', '');
|
||||||
|
setActionOutput('error_message', `Uncaught exception: ${error.message}`);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理未处理的 Promise 拒绝
|
||||||
|
*
|
||||||
|
* @param {*} reason - 拒绝原因
|
||||||
|
* @param {Promise} promise - Promise 对象
|
||||||
|
*/
|
||||||
|
function handleUnhandledRejection(reason, promise) {
|
||||||
|
logError('💥 Unhandled promise rejection occurred!');
|
||||||
|
logError(`Reason: ${reason}`);
|
||||||
|
logError(`Promise: ${promise}`);
|
||||||
|
|
||||||
|
setActionOutput('success', 'false');
|
||||||
|
setActionOutput('message_id', '');
|
||||||
|
setActionOutput('error_message', `Unhandled rejection: ${reason}`);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理进程退出
|
||||||
|
*
|
||||||
|
* @param {number} code - 退出码
|
||||||
|
*/
|
||||||
|
function handleExit(code) {
|
||||||
|
if (code !== 0) {
|
||||||
|
logError(`Process exiting with code: ${code}`);
|
||||||
|
} else {
|
||||||
|
logInfo('🎉 Action completed successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册全局错误处理器
|
||||||
|
process.on('uncaughtException', handleUncaughtException);
|
||||||
|
process.on('unhandledRejection', handleUnhandledRejection);
|
||||||
|
process.on('exit', handleExit);
|
||||||
|
|
||||||
|
// 主程序入口
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
main()
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
logInfo('🎯 Action execution completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
logError('❌ Action execution failed');
|
||||||
|
setActionFailed(result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
logError('💥 Fatal error in main execution');
|
||||||
|
logError(`Error: ${error.message}`);
|
||||||
|
logError(`Stack: ${error.stack}`);
|
||||||
|
setActionFailed(`Fatal error: ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出主函数供测试使用
|
||||||
|
export { main };
|
||||||
448
src/message.js
Normal file
448
src/message.js
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/**
|
||||||
|
* 消息构建模块
|
||||||
|
* 根据不同消息类型构建符合钉钉 API 格式的消息体
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MESSAGE_TYPES } from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉消息基础结构
|
||||||
|
*
|
||||||
|
* @typedef {object} DingTalkMessage
|
||||||
|
* @property {string} msgtype - 消息类型
|
||||||
|
* @property {object} [text] - 文本消息内容
|
||||||
|
* @property {object} [markdown] - Markdown 消息内容
|
||||||
|
* @property {object} [link] - 链接消息内容
|
||||||
|
* @property {object} [actionCard] - ActionCard 消息内容
|
||||||
|
* @property {object} [feedCard] - FeedCard 消息内容
|
||||||
|
* @property {object} [at] - @ 相关配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At相关配置
|
||||||
|
*
|
||||||
|
* @typedef {object} AtConfig
|
||||||
|
* @property {string[]} atMobiles - @的手机号列表
|
||||||
|
* @property {boolean} isAtAll - 是否@所有人
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建@配置对象
|
||||||
|
*
|
||||||
|
* @param {string[]} atMobiles - @的手机号列表
|
||||||
|
* @param {boolean} atAll - 是否@所有人
|
||||||
|
* @returns {AtConfig|undefined} @配置对象,如果没有@需求则返回undefined
|
||||||
|
*/
|
||||||
|
function buildAtConfig(atMobiles = [], atAll = false) {
|
||||||
|
if (!atAll && (!atMobiles || atMobiles.length === 0)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
atMobiles: atMobiles || [],
|
||||||
|
isAtAll: atAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建文本消息
|
||||||
|
*
|
||||||
|
* @param {string} content - 消息内容
|
||||||
|
* @param {string[]} [atMobiles] - @的手机号列表
|
||||||
|
* @param {boolean} [atAll] - 是否@所有人
|
||||||
|
* @returns {DingTalkMessage} 钉钉文本消息格式
|
||||||
|
*/
|
||||||
|
export function buildTextMessage(content, atMobiles, atAll) {
|
||||||
|
if (!content || typeof content !== 'string') {
|
||||||
|
throw new Error('Content is required for text message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
msgtype: MESSAGE_TYPES.TEXT,
|
||||||
|
text: {
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const atConfig = buildAtConfig(atMobiles, atAll);
|
||||||
|
if (atConfig) {
|
||||||
|
message.at = atConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 Markdown 消息
|
||||||
|
*
|
||||||
|
* @param {string} title - 消息标题
|
||||||
|
* @param {string} content - Markdown 内容
|
||||||
|
* @param {string[]} [atMobiles] - @的手机号列表
|
||||||
|
* @param {boolean} [atAll] - 是否@所有人
|
||||||
|
* @returns {DingTalkMessage} 钉钉 Markdown 消息格式
|
||||||
|
*/
|
||||||
|
export function buildMarkdownMessage(title, content, atMobiles, atAll) {
|
||||||
|
if (!title || typeof title !== 'string') {
|
||||||
|
throw new Error('Title is required for markdown message');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content || typeof content !== 'string') {
|
||||||
|
throw new Error('Content is required for markdown message');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果需要@人员,在 Markdown 内容中添加@信息
|
||||||
|
let markdownContent = content;
|
||||||
|
if (atMobiles && atMobiles.length > 0) {
|
||||||
|
const atText = atMobiles.map(mobile => `@${mobile}`).join(' ');
|
||||||
|
markdownContent += `\n\n${atText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atAll) {
|
||||||
|
markdownContent += '\n\n@所有人';
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
msgtype: MESSAGE_TYPES.MARKDOWN,
|
||||||
|
markdown: {
|
||||||
|
title,
|
||||||
|
text: markdownContent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const atConfig = buildAtConfig(atMobiles, atAll);
|
||||||
|
if (atConfig) {
|
||||||
|
message.at = atConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建链接消息
|
||||||
|
*
|
||||||
|
* @param {string} title - 消息标题
|
||||||
|
* @param {string} content - 消息内容
|
||||||
|
* @param {string} linkUrl - 链接地址
|
||||||
|
* @param {string} [picUrl] - 图片地址
|
||||||
|
* @returns {DingTalkMessage} 钉钉链接消息格式
|
||||||
|
*/
|
||||||
|
export function buildLinkMessage(title, content, linkUrl, picUrl) {
|
||||||
|
if (!title || typeof title !== 'string') {
|
||||||
|
throw new Error('Title is required for link message');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content || typeof content !== 'string') {
|
||||||
|
throw new Error('Content is required for link message');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linkUrl || typeof linkUrl !== 'string') {
|
||||||
|
throw new Error('Link URL is required for link message');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证链接格式
|
||||||
|
if (!/^https?:\/\/.+/.test(linkUrl)) {
|
||||||
|
throw new Error('Link URL must be a valid HTTP/HTTPS URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkMessage = {
|
||||||
|
title,
|
||||||
|
text: content,
|
||||||
|
messageUrl: linkUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加图片URL(如果提供)
|
||||||
|
if (picUrl && typeof picUrl === 'string' && /^https?:\/\/.+/.test(picUrl)) {
|
||||||
|
linkMessage.picUrl = picUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
msgtype: MESSAGE_TYPES.LINK,
|
||||||
|
link: linkMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 ActionCard 消息
|
||||||
|
*
|
||||||
|
* @param {string} title - 消息标题
|
||||||
|
* @param {string} content - 消息内容(Markdown 支持)
|
||||||
|
* @param {object} options - 选项
|
||||||
|
* @param {string} options.btnOrientation - 按钮排列方向('0'竖直,'1'水平)
|
||||||
|
* @param {string} [options.singleTitle] - 单按钮标题(整体跳转)
|
||||||
|
* @param {string} [options.singleURL] - 单按钮链接(整体跳转)
|
||||||
|
* @param {{ title: string, actionURL: string }[]} [options.buttons] - 多按钮配置(独立跳转)
|
||||||
|
* @returns {DingTalkMessage} 钉钉 ActionCard 消息格式
|
||||||
|
*/
|
||||||
|
export function buildActionCardMessage(title, content, options) {
|
||||||
|
if (!title || typeof title !== 'string') {
|
||||||
|
throw new Error('Title is required for actionCard message');
|
||||||
|
}
|
||||||
|
if (!content || typeof content !== 'string') {
|
||||||
|
throw new Error('Content is required for actionCard message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { btnOrientation = '0', singleTitle, singleURL, buttons = [] } = options || {};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
text: content,
|
||||||
|
btnOrientation,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSingle = !!singleTitle && !!singleURL;
|
||||||
|
const hasButtons = Array.isArray(buttons) && buttons.length > 0;
|
||||||
|
|
||||||
|
if (!hasSingle && !hasButtons) {
|
||||||
|
throw new Error('actionCard requires either singleTitle/singleURL or buttons');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSingle) {
|
||||||
|
payload.singleTitle = singleTitle;
|
||||||
|
payload.singleURL = singleURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasButtons) {
|
||||||
|
// 钉钉字段为 btns
|
||||||
|
payload.btns = buttons.map(b => ({ title: b.title, actionURL: b.actionURL }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
msgtype: MESSAGE_TYPES.ACTION_CARD,
|
||||||
|
actionCard: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 FeedCard 消息
|
||||||
|
*
|
||||||
|
* @param {{ title: string, messageURL: string, picURL: string }[]} links - 图文链接列表
|
||||||
|
* @returns {DingTalkMessage} 钉钉 FeedCard 消息格式
|
||||||
|
*/
|
||||||
|
export function buildFeedCardMessage(links) {
|
||||||
|
if (!Array.isArray(links) || links.length === 0) {
|
||||||
|
throw new Error('FeedCard requires a non-empty links array');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
msgtype: MESSAGE_TYPES.FEED_CARD,
|
||||||
|
feedCard: {
|
||||||
|
links: links.map(l => ({ title: l.title, messageURL: l.messageURL, picURL: l.picURL })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据配置构建消息
|
||||||
|
*
|
||||||
|
* @param {object} config - 配置对象
|
||||||
|
* @param {string} config.messageType - 消息类型
|
||||||
|
* @param {string} config.content - 消息内容
|
||||||
|
* @param {string} [config.title] - 消息标题
|
||||||
|
* @param {string[]} [config.atMobiles] - @的手机号列表
|
||||||
|
* @param {boolean} [config.atAll] - 是否@所有人
|
||||||
|
* @param {string} [config.linkUrl] - 链接地址
|
||||||
|
* @param {string} [config.picUrl] - 图片地址
|
||||||
|
* @returns {DingTalkMessage} 钉钉消息格式
|
||||||
|
* @throws {Error} 当消息类型不支持或参数不正确时抛出错误
|
||||||
|
*/
|
||||||
|
export function buildMessage(config) {
|
||||||
|
const {
|
||||||
|
messageType,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
atMobiles,
|
||||||
|
atAll,
|
||||||
|
linkUrl,
|
||||||
|
picUrl,
|
||||||
|
btnOrientation,
|
||||||
|
singleTitle,
|
||||||
|
singleURL,
|
||||||
|
buttons,
|
||||||
|
feedLinks,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
switch (messageType) {
|
||||||
|
case MESSAGE_TYPES.TEXT:
|
||||||
|
return buildTextMessage(content, atMobiles, atAll);
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.MARKDOWN:
|
||||||
|
return buildMarkdownMessage(title, content, atMobiles, atAll);
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.LINK:
|
||||||
|
return buildLinkMessage(title, content, linkUrl, picUrl);
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.ACTION_CARD:
|
||||||
|
return buildActionCardMessage(title, content, {
|
||||||
|
btnOrientation,
|
||||||
|
singleTitle,
|
||||||
|
singleURL,
|
||||||
|
buttons,
|
||||||
|
});
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.FEED_CARD:
|
||||||
|
return buildFeedCardMessage(feedLinks);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported message type: ${messageType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证消息格式
|
||||||
|
*
|
||||||
|
* @param {DingTalkMessage} message - 钉钉消息对象
|
||||||
|
* @returns {object} 验证结果
|
||||||
|
*/
|
||||||
|
export function validateMessage(message) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!message || typeof message !== 'object') {
|
||||||
|
errors.push('Message must be an object');
|
||||||
|
return { isValid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.msgtype || !Object.values(MESSAGE_TYPES).includes(message.msgtype)) {
|
||||||
|
errors.push('Invalid or missing msgtype');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据消息类型验证特定字段
|
||||||
|
switch (message.msgtype) {
|
||||||
|
case MESSAGE_TYPES.TEXT:
|
||||||
|
if (!message.text || !message.text.content) {
|
||||||
|
errors.push('Text message must have content');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.MARKDOWN:
|
||||||
|
if (!message.markdown || !message.markdown.title || !message.markdown.text) {
|
||||||
|
errors.push('Markdown message must have title and text');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.LINK:
|
||||||
|
if (!message.link || !message.link.title || !message.link.text || !message.link.messageUrl) {
|
||||||
|
errors.push('Link message must have title, text, and messageUrl');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.ACTION_CARD:
|
||||||
|
if (!message.actionCard || !message.actionCard.title || !message.actionCard.text) {
|
||||||
|
errors.push('ActionCard message must have title and text');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(message.actionCard.singleTitle && !message.actionCard.singleURL) ||
|
||||||
|
(message.actionCard.singleURL && !message.actionCard.singleTitle)
|
||||||
|
) {
|
||||||
|
errors.push('ActionCard single button requires both singleTitle and singleURL');
|
||||||
|
}
|
||||||
|
if (message.actionCard.btns && Array.isArray(message.actionCard.btns)) {
|
||||||
|
for (const btn of message.actionCard.btns) {
|
||||||
|
if (!btn.title || !btn.actionURL) {
|
||||||
|
errors.push('Each ActionCard button must have title and actionURL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.FEED_CARD:
|
||||||
|
if (
|
||||||
|
!message.feedCard ||
|
||||||
|
!Array.isArray(message.feedCard.links) ||
|
||||||
|
message.feedCard.links.length === 0
|
||||||
|
) {
|
||||||
|
errors.push('FeedCard must have a non-empty links array');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (const link of message.feedCard.links) {
|
||||||
|
if (!link.title || !link.messageURL || !link.picURL) {
|
||||||
|
errors.push('Each FeedCard link must have title, messageURL and picURL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息摘要信息(用于日志输出)
|
||||||
|
*
|
||||||
|
* @param {DingTalkMessage} message - 钉钉消息对象
|
||||||
|
* @returns {string} 消息摘要
|
||||||
|
*/
|
||||||
|
export function getMessageSummary(message) {
|
||||||
|
if (!message || !message.msgtype) {
|
||||||
|
return 'Invalid message';
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = message.msgtype;
|
||||||
|
let summary = `Type: ${type}`;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case MESSAGE_TYPES.TEXT:
|
||||||
|
if (message.text && message.text.content) {
|
||||||
|
const contentPreview =
|
||||||
|
message.text.content.length > 50
|
||||||
|
? `${message.text.content.substring(0, 50)}...`
|
||||||
|
: message.text.content;
|
||||||
|
summary += `, Content: "${contentPreview}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.MARKDOWN:
|
||||||
|
if (message.markdown) {
|
||||||
|
summary += `, Title: "${message.markdown.title}"`;
|
||||||
|
if (message.markdown.text) {
|
||||||
|
const textPreview =
|
||||||
|
message.markdown.text.length > 50
|
||||||
|
? `${message.markdown.text.substring(0, 50)}...`
|
||||||
|
: message.markdown.text;
|
||||||
|
summary += `, Text: "${textPreview}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.LINK:
|
||||||
|
if (message.link) {
|
||||||
|
summary += `, Title: "${message.link.title}"`;
|
||||||
|
summary += `, URL: "${message.link.messageUrl}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.ACTION_CARD:
|
||||||
|
if (message.actionCard) {
|
||||||
|
summary += `, Title: "${message.actionCard.title}"`;
|
||||||
|
if (message.actionCard.singleTitle) {
|
||||||
|
summary += `, Single: "${message.actionCard.singleTitle}"`;
|
||||||
|
}
|
||||||
|
if (message.actionCard.btns) {
|
||||||
|
summary += `, Buttons: ${message.actionCard.btns.length}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_TYPES.FEED_CARD:
|
||||||
|
if (message.feedCard && Array.isArray(message.feedCard.links)) {
|
||||||
|
summary += `, Links: ${message.feedCard.links.length}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加@信息
|
||||||
|
if (message.at) {
|
||||||
|
if (message.at.isAtAll) {
|
||||||
|
summary += ', @All: true';
|
||||||
|
}
|
||||||
|
if (message.at.atMobiles && message.at.atMobiles.length > 0) {
|
||||||
|
summary += `, @Mobiles: ${message.at.atMobiles.length}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
349
src/utils.js
Normal file
349
src/utils.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* 工具函数模块
|
||||||
|
* 提供通用的辅助功能和实用工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志级别枚举
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export const LOG_LEVELS = {
|
||||||
|
DEBUG: 'DEBUG',
|
||||||
|
INFO: 'INFO',
|
||||||
|
WARN: 'WARN',
|
||||||
|
ERROR: 'ERROR',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前时间戳字符串
|
||||||
|
*
|
||||||
|
* @returns {string} 格式化的时间戳
|
||||||
|
*/
|
||||||
|
export function getTimestamp() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日志消息
|
||||||
|
*
|
||||||
|
* @param {string} level - 日志级别
|
||||||
|
* @param {string} message - 日志消息
|
||||||
|
* @param {object} [meta] - 额外的元数据
|
||||||
|
* @returns {string} 格式化后的日志消息
|
||||||
|
*/
|
||||||
|
export function formatLogMessage(level, message, meta = null) {
|
||||||
|
const timestamp = getTimestamp();
|
||||||
|
let logMessage = `[${timestamp}] [${level}] ${message}`;
|
||||||
|
|
||||||
|
if (meta && typeof meta === 'object') {
|
||||||
|
logMessage += ` | ${JSON.stringify(meta)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return logMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出调试日志
|
||||||
|
*
|
||||||
|
* @param {string} message - 日志消息
|
||||||
|
* @param {object} [meta] - 额外的元数据
|
||||||
|
*/
|
||||||
|
export function logDebug(message, meta) {
|
||||||
|
if (process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(formatLogMessage(LOG_LEVELS.DEBUG, message, meta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出信息日志
|
||||||
|
*
|
||||||
|
* @param {string} message - 日志消息
|
||||||
|
* @param {object} [meta] - 额外的元数据
|
||||||
|
*/
|
||||||
|
export function logInfo(message, meta) {
|
||||||
|
console.log(formatLogMessage(LOG_LEVELS.INFO, message, meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出警告日志
|
||||||
|
*
|
||||||
|
* @param {string} message - 日志消息
|
||||||
|
* @param {object} [meta] - 额外的元数据
|
||||||
|
*/
|
||||||
|
export function logWarn(message, meta) {
|
||||||
|
console.warn(formatLogMessage(LOG_LEVELS.WARN, message, meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出错误日志
|
||||||
|
*
|
||||||
|
* @param {string} message - 日志消息
|
||||||
|
* @param {object} [meta] - 额外的元数据
|
||||||
|
*/
|
||||||
|
export function logError(message, meta) {
|
||||||
|
console.error(formatLogMessage(LOG_LEVELS.ERROR, message, meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地解析 JSON 字符串
|
||||||
|
*
|
||||||
|
* @param {string} jsonString - JSON 字符串
|
||||||
|
* @param {*} [defaultValue] - 解析失败时的默认值
|
||||||
|
* @returns {*} 解析结果或默认值
|
||||||
|
*/
|
||||||
|
export function safeJsonParse(jsonString, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonString);
|
||||||
|
} catch (error) {
|
||||||
|
logWarn(`Failed to parse JSON: ${error.message}`, { jsonString });
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地序列化对象为 JSON 字符串
|
||||||
|
*
|
||||||
|
* @param {*} obj - 要序列化的对象
|
||||||
|
* @param {string} [defaultValue] - 序列化失败时的默认值
|
||||||
|
* @returns {string} JSON 字符串或默认值
|
||||||
|
*/
|
||||||
|
export function safeJsonStringify(obj, defaultValue = '{}') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
// 避免循环引用问题,不传递 obj 到 logWarn
|
||||||
|
logWarn(`Failed to stringify object: ${error.message}`);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字符串是否为空或只包含空白字符
|
||||||
|
*
|
||||||
|
* @param {string} str - 要检查的字符串
|
||||||
|
* @returns {boolean} 是否为空
|
||||||
|
*/
|
||||||
|
export function isEmpty(str) {
|
||||||
|
return !str || typeof str !== 'string' || str.trim().length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断字符串到指定长度
|
||||||
|
*
|
||||||
|
* @param {string} str - 原字符串
|
||||||
|
* @param {number} maxLength - 最大长度
|
||||||
|
* @param {string} [suffix] - 截断后的后缀
|
||||||
|
* @returns {string} 截断后的字符串
|
||||||
|
*/
|
||||||
|
export function truncateString(str, maxLength, suffix = '...') {
|
||||||
|
if (!str || typeof str !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str.length <= maxLength) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str.substring(0, maxLength - suffix.length) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理和验证手机号
|
||||||
|
*
|
||||||
|
* @param {string} mobile - 手机号字符串
|
||||||
|
* @returns {string|null} 清理后的手机号或null
|
||||||
|
*/
|
||||||
|
export function cleanMobile(mobile) {
|
||||||
|
if (!mobile || typeof mobile !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除所有非数字字符
|
||||||
|
const cleaned = mobile.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// 验证中国大陆手机号格式
|
||||||
|
if (/^1[3-9]\d{9}$/.test(cleaned)) {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 URL 格式
|
||||||
|
*
|
||||||
|
* @param {string} url - URL 字符串
|
||||||
|
* @returns {boolean} 是否为有效的 URL
|
||||||
|
*/
|
||||||
|
export function isValidUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 HTTP/HTTPS URL
|
||||||
|
*
|
||||||
|
* @param {string} url - URL 字符串
|
||||||
|
* @returns {boolean} 是否为有效的 HTTP/HTTPS URL
|
||||||
|
*/
|
||||||
|
export function isValidHttpUrl(url) {
|
||||||
|
if (!isValidUrl(url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^https?:\/\/.+/.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建错误对象
|
||||||
|
*
|
||||||
|
* @param {string} message - 错误消息
|
||||||
|
* @param {string} [code] - 错误代码
|
||||||
|
* @param {*} [details] - 错误详情
|
||||||
|
* @returns {Error} 错误对象
|
||||||
|
*/
|
||||||
|
export function createError(message, code = null, details = null) {
|
||||||
|
const error = new Error(message);
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
error.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
error.details = details;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 GitHub Actions 输出
|
||||||
|
*
|
||||||
|
* @param {string} name - 输出名称
|
||||||
|
* @param {string} value - 输出值
|
||||||
|
*/
|
||||||
|
export function setActionOutput(name, value) {
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
// GitHub Actions 新格式
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||||
|
} else {
|
||||||
|
// 兼容旧格式
|
||||||
|
console.log(`::set-output name=${name}::${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 GitHub Actions 失败状态
|
||||||
|
*
|
||||||
|
* @param {string} message - 失败消息
|
||||||
|
*/
|
||||||
|
export function setActionFailed(message) {
|
||||||
|
console.log(`::error::${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取环境变量,支持默认值
|
||||||
|
*
|
||||||
|
* @param {string} name - 环境变量名
|
||||||
|
* @param {string} [defaultValue] - 默认值
|
||||||
|
* @returns {string} 环境变量值或默认值
|
||||||
|
*/
|
||||||
|
export function getEnv(name, defaultValue = '') {
|
||||||
|
return process.env[name] || defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否在调试模式
|
||||||
|
*
|
||||||
|
* @returns {boolean} 是否为调试模式
|
||||||
|
*/
|
||||||
|
export function isDebugMode() {
|
||||||
|
return getEnv('DEBUG', 'false').toLowerCase() === 'true' || getEnv('NODE_ENV') === 'development';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化持续时间
|
||||||
|
*
|
||||||
|
* @param {number} milliseconds - 毫秒数
|
||||||
|
* @returns {string} 格式化的持续时间
|
||||||
|
*/
|
||||||
|
export function formatDuration(milliseconds) {
|
||||||
|
if (milliseconds < 1000) {
|
||||||
|
return `${milliseconds}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = (milliseconds / 1000).toFixed(2);
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建重试配置
|
||||||
|
*
|
||||||
|
* @param {number} [maxRetries] - 最大重试次数
|
||||||
|
* @param {number} [delay] - 重试延迟(毫秒)
|
||||||
|
* @param {number} [backoffMultiplier] - 退避乘数
|
||||||
|
* @returns {object} 重试配置
|
||||||
|
*/
|
||||||
|
export function createRetryConfig(maxRetries = 3, delay = 1000, backoffMultiplier = 1.5) {
|
||||||
|
return {
|
||||||
|
maxRetries,
|
||||||
|
delay,
|
||||||
|
backoffMultiplier,
|
||||||
|
getDelay: attempt => Math.floor(delay * Math.pow(backoffMultiplier, attempt)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 掩码敏感信息
|
||||||
|
*
|
||||||
|
* @param {string} str - 原字符串
|
||||||
|
* @param {number} [visibleStart] - 开始可见字符数
|
||||||
|
* @param {number} [visibleEnd] - 结尾可见字符数
|
||||||
|
* @param {string} [maskChar] - 掩码字符
|
||||||
|
* @returns {string} 掩码后的字符串
|
||||||
|
*/
|
||||||
|
export function maskSensitiveInfo(str, visibleStart = 4, visibleEnd = 4, maskChar = '*') {
|
||||||
|
if (!str || typeof str !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str.length <= visibleStart + visibleEnd) {
|
||||||
|
return maskChar.repeat(str.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = str.substring(0, visibleStart);
|
||||||
|
const end = str.substring(str.length - visibleEnd);
|
||||||
|
const maskLength = str.length - visibleStart - visibleEnd;
|
||||||
|
|
||||||
|
return start + maskChar.repeat(maskLength) + end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证必需的环境变量
|
||||||
|
*
|
||||||
|
* @param {string[]} requiredVars - 必需的环境变量名列表
|
||||||
|
* @throws {Error} 当缺少必需的环境变量时抛出错误
|
||||||
|
*/
|
||||||
|
export function validateRequiredEnvVars(requiredVars) {
|
||||||
|
const missing = requiredVars.filter(varName => !process.env[varName]);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw createError(
|
||||||
|
`Missing required environment variables: ${missing.join(', ')}`,
|
||||||
|
'MISSING_ENV_VARS',
|
||||||
|
{ missing },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
407
tests/fixtures/configs/invalid-configs.js
vendored
Normal file
407
tests/fixtures/configs/invalid-configs.js
vendored
Normal 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
378
tests/fixtures/configs/valid-configs.js
vendored
Normal 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: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
366
tests/fixtures/responses/dingtalk-responses.js
vendored
Normal file
366
tests/fixtures/responses/dingtalk-responses.js
vendored
Normal 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
243
tests/helpers/assertions.js
Normal 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
111
tests/helpers/env-mock.js
Normal 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
267
tests/helpers/http-mock.js
Normal 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])}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
495
tests/integration/index.test.js
Normal file
495
tests/integration/index.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
tests/setup/global-setup.js
Normal file
44
tests/setup/global-setup.js
Normal 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
592
tests/unit/config.test.js
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
/**
|
||||||
|
* config.js 模块单元测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
MESSAGE_TYPES,
|
||||||
|
logConfig,
|
||||||
|
parseConfig,
|
||||||
|
validateMessageType,
|
||||||
|
validateWebhookUrl,
|
||||||
|
} from '../../src/config.js';
|
||||||
|
import { envConfigs } from '../fixtures/configs/valid-configs.js';
|
||||||
|
import { assertConfigStructure } from '../helpers/assertions.js';
|
||||||
|
import { mockEnvVars } from '../helpers/env-mock.js';
|
||||||
|
|
||||||
|
describe('测试 config.js 文件', () => {
|
||||||
|
let restoreEnv;
|
||||||
|
let consoleSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock console methods
|
||||||
|
consoleSpy = {
|
||||||
|
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore console methods
|
||||||
|
Object.values(consoleSpy).forEach(spy => spy.mockRestore());
|
||||||
|
|
||||||
|
// Restore environment variables
|
||||||
|
if (restoreEnv) {
|
||||||
|
restoreEnv();
|
||||||
|
restoreEnv = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 MESSAGE_TYPES 常量', () => {
|
||||||
|
it('应该导出正确的消息类型', () => {
|
||||||
|
expect(MESSAGE_TYPES).toEqual({
|
||||||
|
TEXT: 'text',
|
||||||
|
MARKDOWN: 'markdown',
|
||||||
|
LINK: 'link',
|
||||||
|
ACTION_CARD: 'actionCard',
|
||||||
|
FEED_CARD: 'feedCard',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该是只读枚举', () => {
|
||||||
|
expect(() => {
|
||||||
|
MESSAGE_TYPES.NEW_TYPE = 'newType';
|
||||||
|
}).not.toThrow(); // JavaScript doesn't prevent this, but it's documented as readonly
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 validateWebhookUrl 函数', () => {
|
||||||
|
it('应该验证正确的钉钉 Webhook URL', () => {
|
||||||
|
const validUrls = [
|
||||||
|
'https://oapi.dingtalk.com/robot/send?access_token=abc123',
|
||||||
|
'https://oapi.dingtalk.com/robot/send?access_token=test-token-123',
|
||||||
|
'https://oapi.dingtalk.com/robot/send?access_token=1234567890abcdef',
|
||||||
|
];
|
||||||
|
|
||||||
|
validUrls.forEach(url => {
|
||||||
|
expect(validateWebhookUrl(url)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝无效的 Webhook URL', () => {
|
||||||
|
const invalidUrls = [
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
'not-a-url',
|
||||||
|
'http://oapi.dingtalk.com/robot/send?access_token=abc123', // http instead of https
|
||||||
|
'https://wrong-domain.com/robot/send?access_token=abc123',
|
||||||
|
'https://oapi.dingtalk.com/wrong/path?access_token=abc123',
|
||||||
|
'https://oapi.dingtalk.com/robot/send', // missing access_token
|
||||||
|
'https://oapi.dingtalk.com/robot/send?access_token=', // empty token
|
||||||
|
'https://oapi.dingtalk.com/robot/send?wrong_param=abc123',
|
||||||
|
];
|
||||||
|
|
||||||
|
invalidUrls.forEach(url => {
|
||||||
|
expect(validateWebhookUrl(url)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理非字符串输入', () => {
|
||||||
|
expect(validateWebhookUrl(123)).toBe(false);
|
||||||
|
expect(validateWebhookUrl({})).toBe(false);
|
||||||
|
expect(validateWebhookUrl([])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 validateMessageType 函数', () => {
|
||||||
|
it('应该验证支持的消息类型', () => {
|
||||||
|
Object.values(MESSAGE_TYPES).forEach(type => {
|
||||||
|
expect(validateMessageType(type)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝不支持的消息类型', () => {
|
||||||
|
const invalidTypes = [
|
||||||
|
'unsupported',
|
||||||
|
'TEXT', // wrong case
|
||||||
|
'MARKDOWN', // wrong case
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
123,
|
||||||
|
];
|
||||||
|
|
||||||
|
invalidTypes.forEach(type => {
|
||||||
|
expect(validateMessageType(type)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 parseConfig 函数', () => {
|
||||||
|
describe('有效配置测试', () => {
|
||||||
|
it('应该解析基础文本消息配置', async () => {
|
||||||
|
restoreEnv = mockEnvVars(envConfigs.basicText);
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
assertConfigStructure(config, ['webhookUrl', 'messageType', 'content']);
|
||||||
|
expect(config.messageType).toBe('text');
|
||||||
|
expect(config.content).toBe('Hello, World!');
|
||||||
|
expect(config.atAll).toBe(false);
|
||||||
|
expect(config.atMobiles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该解析 Markdown 消息配置', async () => {
|
||||||
|
restoreEnv = mockEnvVars(envConfigs.markdown);
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
expect(config.messageType).toBe('markdown');
|
||||||
|
expect(config.title).toBe('Markdown 标题');
|
||||||
|
expect(config.content).toContain('Markdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该解析带 @all 的文本消息配置', async () => {
|
||||||
|
restoreEnv = mockEnvVars(envConfigs.textWithAtAll);
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
expect(config.atAll).toBe(true);
|
||||||
|
expect(config.messageType).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析手机号码', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
...envConfigs.basicText,
|
||||||
|
INPUT_AT_MOBILES: '13800138000,13900139000,invalid-mobile,15012345678',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
expect(config.atMobiles).toEqual(['13800138000', '13900139000', '15012345678']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该解析链接消息配置', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'link',
|
||||||
|
INPUT_CONTENT: '链接描述',
|
||||||
|
INPUT_TITLE: '链接标题',
|
||||||
|
INPUT_LINK_URL: 'https://example.com',
|
||||||
|
INPUT_PIC_URL: 'https://example.com/image.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
expect(config.messageType).toBe('link');
|
||||||
|
expect(config.title).toBe('链接标题');
|
||||||
|
expect(config.linkUrl).toBe('https://example.com');
|
||||||
|
expect(config.picUrl).toBe('https://example.com/image.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该解析单按钮 ActionCard 配置', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard 内容',
|
||||||
|
INPUT_TITLE: 'ActionCard 标题',
|
||||||
|
INPUT_BTN_ORIENTATION: '0',
|
||||||
|
INPUT_SINGLE_TITLE: '查看详情',
|
||||||
|
INPUT_SINGLE_URL: 'https://example.com/detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
expect(config.messageType).toBe('actionCard');
|
||||||
|
expect(config.btnOrientation).toBe('0');
|
||||||
|
expect(config.singleTitle).toBe('查看详情');
|
||||||
|
expect(config.singleURL).toBe('https://example.com/detail');
|
||||||
|
expect(config.buttons).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该解析多按钮 ActionCard 配置', async () => {
|
||||||
|
const buttons = JSON.stringify([
|
||||||
|
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||||
|
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard 内容',
|
||||||
|
INPUT_TITLE: 'ActionCard 标题',
|
||||||
|
INPUT_BTN_ORIENTATION: '1',
|
||||||
|
INPUT_BUTTONS: buttons,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
expect(config.messageType).toBe('actionCard');
|
||||||
|
expect(config.btnOrientation).toBe('1');
|
||||||
|
expect(config.buttons).toHaveLength(2);
|
||||||
|
expect(config.buttons[0]).toEqual({
|
||||||
|
title: '按钮1',
|
||||||
|
actionURL: 'https://example.com/action1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该解析 FeedCard 配置', async () => {
|
||||||
|
const feedLinks = JSON.stringify([
|
||||||
|
{
|
||||||
|
title: '新闻1',
|
||||||
|
messageURL: 'https://example.com/news1',
|
||||||
|
picURL: 'https://example.com/pic1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '新闻2',
|
||||||
|
messageURL: 'https://example.com/news2',
|
||||||
|
picURL: 'https://example.com/pic2.jpg',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||||
|
INPUT_CONTENT: 'FeedCard content',
|
||||||
|
INPUT_FEED_LINKS: feedLinks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
|
||||||
|
expect(config.messageType).toBe('feedCard');
|
||||||
|
expect(config.feedLinks).toHaveLength(2);
|
||||||
|
expect(config.feedLinks[0]).toEqual({
|
||||||
|
title: '新闻1',
|
||||||
|
messageURL: 'https://example.com/news1',
|
||||||
|
picURL: 'https://example.com/pic1.jpg',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('无效配置测试', () => {
|
||||||
|
it('应该抛出缺少 Webhook URL 的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: '',
|
||||||
|
INPUT_MESSAGE_TYPE: 'text',
|
||||||
|
INPUT_CONTENT: 'Hello, World!',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('webhook_url is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出无效 Webhook URL 格式的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'not-a-valid-url',
|
||||||
|
INPUT_MESSAGE_TYPE: 'text',
|
||||||
|
INPUT_CONTENT: 'Hello, World!',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('webhook_url format is invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出缺少内容的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'text',
|
||||||
|
INPUT_CONTENT: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('content is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出不支持的消息类型错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'unsupported',
|
||||||
|
INPUT_CONTENT: 'Hello, World!',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('message_type must be one of');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 Markdown 消息缺少标题的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'markdown',
|
||||||
|
INPUT_CONTENT: '## Content',
|
||||||
|
INPUT_TITLE: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('title is required for markdown message type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 Link 消息缺少标题的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'link',
|
||||||
|
INPUT_CONTENT: 'Link description',
|
||||||
|
INPUT_TITLE: '',
|
||||||
|
INPUT_LINK_URL: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('title is required for link message type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出缺少链接 URL 的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'link',
|
||||||
|
INPUT_CONTENT: 'Link description',
|
||||||
|
INPUT_TITLE: 'Link title',
|
||||||
|
INPUT_LINK_URL: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('link_url is required for link message type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出无效链接 URL 的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'link',
|
||||||
|
INPUT_CONTENT: 'Link description',
|
||||||
|
INPUT_TITLE: 'Link title',
|
||||||
|
INPUT_LINK_URL: 'not-a-valid-url',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow('link_url must be a valid HTTP/HTTPS URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 ActionCard 缺少标题的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard content',
|
||||||
|
INPUT_TITLE: '',
|
||||||
|
INPUT_SINGLE_TITLE: 'Button',
|
||||||
|
INPUT_SINGLE_URL: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow(
|
||||||
|
'title is required for actionCard message type',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 ActionCard 缺少按钮的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard content',
|
||||||
|
INPUT_TITLE: 'ActionCard title',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow(
|
||||||
|
'actionCard requires either single_title/single_url or buttons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 ActionCard 无效按钮方向的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard content',
|
||||||
|
INPUT_TITLE: 'ActionCard title',
|
||||||
|
INPUT_BTN_ORIENTATION: '2',
|
||||||
|
INPUT_SINGLE_TITLE: 'Button',
|
||||||
|
INPUT_SINGLE_URL: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow("btn_orientation must be '0' or '1'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 ActionCard 单按钮缺少 URL 的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard content',
|
||||||
|
INPUT_TITLE: 'ActionCard title',
|
||||||
|
INPUT_SINGLE_TITLE: 'Button',
|
||||||
|
INPUT_SINGLE_URL: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow(
|
||||||
|
'single_url is required when using single button',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 ActionCard 无效按钮 JSON 的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard content',
|
||||||
|
INPUT_TITLE: 'ActionCard title',
|
||||||
|
INPUT_BUTTONS: 'invalid-json',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow(
|
||||||
|
'actionCard requires either single_title/single_url or buttons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 FeedCard 缺少链接的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||||
|
INPUT_CONTENT: '',
|
||||||
|
INPUT_FEED_LINKS: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow(
|
||||||
|
'feed_links is required and must be a non-empty array for feedCard',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出 FeedCard 无效链接 JSON 的错误', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||||
|
INPUT_CONTENT: '',
|
||||||
|
INPUT_FEED_LINKS: 'invalid-json',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseConfig()).rejects.toThrow(
|
||||||
|
'feed_links is required and must be a non-empty array for feedCard',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界情况', () => {
|
||||||
|
it('应该处理按钮方向中的空白字符', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard content',
|
||||||
|
INPUT_TITLE: 'ActionCard title',
|
||||||
|
INPUT_BTN_ORIENTATION: ' 1 ',
|
||||||
|
INPUT_SINGLE_TITLE: 'Button',
|
||||||
|
INPUT_SINGLE_URL: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
expect(config.btnOrientation).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该过滤掉无效的手机号码', async () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'text',
|
||||||
|
INPUT_CONTENT: 'Hello, World!',
|
||||||
|
INPUT_AT_MOBILES: '13800138000,invalid,12345678901,15012345678,abc123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
expect(config.atMobiles).toEqual(['13800138000', '15012345678']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该过滤掉无效的按钮', async () => {
|
||||||
|
const buttons = JSON.stringify([
|
||||||
|
{ title: '有效按钮', actionURL: 'https://example.com/valid' },
|
||||||
|
{ title: '', actionURL: 'https://example.com/invalid' }, // 空标题
|
||||||
|
{ title: '无效URL', actionURL: 'not-a-url' }, // 无效URL
|
||||||
|
{ actionURL: 'https://example.com/no-title' }, // 缺少标题
|
||||||
|
{ title: '另一个有效按钮', actionURL: 'https://example.com/valid2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'actionCard',
|
||||||
|
INPUT_CONTENT: 'ActionCard content',
|
||||||
|
INPUT_TITLE: 'ActionCard title',
|
||||||
|
INPUT_BUTTONS: buttons,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
expect(config.buttons).toHaveLength(2);
|
||||||
|
expect(config.buttons[0].title).toBe('有效按钮');
|
||||||
|
expect(config.buttons[1].title).toBe('另一个有效按钮');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该过滤掉无效的 Feed 链接', async () => {
|
||||||
|
const feedLinks = JSON.stringify([
|
||||||
|
{
|
||||||
|
title: '有效链接',
|
||||||
|
messageURL: 'https://example.com/valid',
|
||||||
|
picURL: 'https://example.com/pic.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
messageURL: 'https://example.com/invalid',
|
||||||
|
picURL: 'https://example.com/pic.jpg',
|
||||||
|
}, // 空标题
|
||||||
|
{
|
||||||
|
title: '无效URL',
|
||||||
|
messageURL: 'not-a-url',
|
||||||
|
picURL: 'https://example.com/pic.jpg',
|
||||||
|
}, // 无效URL
|
||||||
|
{
|
||||||
|
title: '另一个有效链接',
|
||||||
|
messageURL: 'https://example.com/valid2',
|
||||||
|
picURL: 'https://example.com/pic2.jpg',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
INPUT_MESSAGE_TYPE: 'feedCard',
|
||||||
|
INPUT_CONTENT: 'FeedCard content',
|
||||||
|
INPUT_FEED_LINKS: feedLinks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await parseConfig();
|
||||||
|
expect(config.feedLinks).toHaveLength(2);
|
||||||
|
expect(config.feedLinks[0].title).toBe('有效链接');
|
||||||
|
expect(config.feedLinks[1].title).toBe('另一个有效链接');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 logConfig 函数', () => {
|
||||||
|
it('应该记录基本配置信息', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'text',
|
||||||
|
content: 'Hello, World!',
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
};
|
||||||
|
|
||||||
|
logConfig(config);
|
||||||
|
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('=== Action Configuration ===');
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('Message Type: text');
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('Content Length: 13 characters');
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('Webhook URL: [HIDDEN FOR SECURITY]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在字段存在时记录可选字段', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'markdown',
|
||||||
|
content: 'Markdown content',
|
||||||
|
title: 'Test Title',
|
||||||
|
atMobiles: ['13800138000', '13900139000'],
|
||||||
|
atAll: true,
|
||||||
|
linkUrl: 'https://example.com',
|
||||||
|
picUrl: 'https://example.com/pic.jpg',
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
};
|
||||||
|
|
||||||
|
logConfig(config);
|
||||||
|
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('Title: Test Title');
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('At Mobiles: 2 numbers');
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('At All: true');
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('Link URL: https://example.com');
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('Picture URL: https://example.com/pic.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在字段不存在时不记录可选字段', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'text',
|
||||||
|
content: 'Simple text',
|
||||||
|
atMobiles: [],
|
||||||
|
atAll: false,
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123',
|
||||||
|
};
|
||||||
|
|
||||||
|
logConfig(config);
|
||||||
|
|
||||||
|
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Title:'));
|
||||||
|
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('At Mobiles:'));
|
||||||
|
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('At All: true'));
|
||||||
|
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Link URL:'));
|
||||||
|
expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Picture URL:'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
476
tests/unit/http.test.js
Normal file
476
tests/unit/http.test.js
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* http.js 单元测试
|
||||||
|
* 测试 HTTP 客户端功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createRequestSummary,
|
||||||
|
getDingTalkErrorDescription,
|
||||||
|
sendRequest,
|
||||||
|
validateResponse,
|
||||||
|
} from '../../src/http.js';
|
||||||
|
import { dingTalkResponses } from '../fixtures/responses/dingtalk-responses.js';
|
||||||
|
|
||||||
|
// Mock fetch API
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
describe('测试 http.js 文件', () => {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 HTTP Mock 的辅助函数
|
||||||
|
*
|
||||||
|
* @param {object} mockResponse - 模拟的 HTTP 响应
|
||||||
|
*/
|
||||||
|
async function setupHttpMock(mockResponse) {
|
||||||
|
const { statusCode = 200, data } = mockResponse;
|
||||||
|
const responseText = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
status: statusCode,
|
||||||
|
text: () => Promise.resolve(responseText),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置网络错误 Mock 的辅助函数
|
||||||
|
*
|
||||||
|
* @param {string} errorCode - 模拟的 HTTP 错误码
|
||||||
|
*/
|
||||||
|
function setupNetworkErrorMock(errorCode) {
|
||||||
|
const error = new Error(`Network error: ${errorCode}`);
|
||||||
|
error.code = errorCode;
|
||||||
|
mockFetch.mockRejectedValueOnce(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置超时 Mock 的辅助函数
|
||||||
|
*/
|
||||||
|
function setupTimeoutMock() {
|
||||||
|
const error = new Error('Request timeout');
|
||||||
|
error.code = 'TIMEOUT';
|
||||||
|
mockFetch.mockRejectedValueOnce(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock console 方法
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// 恢复所有 mocks
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
|
// 重新设置 fetch mock
|
||||||
|
mockFetch.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 sendRequest 函数', () => {
|
||||||
|
const testUrl = 'https://oapi.dingtalk.com/robot/send?access_token=test';
|
||||||
|
const testMessage = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: 'Hello World',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('应该成功发送请求', async () => {
|
||||||
|
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok',
|
||||||
|
});
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
// 验证 fetch 被调用
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(testUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': expect.stringContaining('DingTalk-Bot'),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(testMessage),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理钉钉 API 错误响应', async () => {
|
||||||
|
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.error.invalidToken });
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false); // 钉钉 API 错误码非0时 success 为 false
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
errcode: 310000,
|
||||||
|
errmsg: 'keywords not in content',
|
||||||
|
});
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理网络连接错误', async () => {
|
||||||
|
setupNetworkErrorMock('ECONNREFUSED');
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.statusCode).toBe(0);
|
||||||
|
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||||
|
expect(result.error).toContain('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理超时错误', async () => {
|
||||||
|
setupTimeoutMock();
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.statusCode).toBe(0);
|
||||||
|
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||||
|
expect(result.error).toContain('timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 DNS 解析错误', async () => {
|
||||||
|
setupNetworkErrorMock('ENOTFOUND');
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.statusCode).toBe(0);
|
||||||
|
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||||
|
expect(result.error).toContain('ENOTFOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 SSL 错误', async () => {
|
||||||
|
setupNetworkErrorMock('CERT_HAS_EXPIRED');
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.statusCode).toBe(0);
|
||||||
|
expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' });
|
||||||
|
expect(result.error).toContain('CERT_HAS_EXPIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理非 JSON 响应', async () => {
|
||||||
|
await setupHttpMock({
|
||||||
|
statusCode: 200,
|
||||||
|
data: 'Not JSON response',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
expect(result.data).toEqual({});
|
||||||
|
expect(result.error).toContain('JSON parse error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空响应', async () => {
|
||||||
|
await setupHttpMock({
|
||||||
|
statusCode: 200,
|
||||||
|
data: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
expect(result.data).toEqual({});
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确设置请求头', async () => {
|
||||||
|
await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard });
|
||||||
|
|
||||||
|
await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(testUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': expect.stringContaining('DingTalk-Bot'),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(testMessage),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 validateResponse 函数', () => {
|
||||||
|
it('应该验证有效的成功响应', () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: {
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok',
|
||||||
|
message_id: 'msg123456',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateResponse(response);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该验证有效的错误响应', () => {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
statusCode: 400,
|
||||||
|
data: {
|
||||||
|
errcode: 310000,
|
||||||
|
errmsg: 'invalid token',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateResponse(response);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该检测缺少必需字段的响应', () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: {
|
||||||
|
errcode: 0,
|
||||||
|
// 缺少 errmsg
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateResponse(response);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Missing required field: errmsg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该检测字段类型错误', () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
statusCode: '200', // 应该是数字
|
||||||
|
data: {
|
||||||
|
errcode: '0', // 应该是数字
|
||||||
|
errmsg: 'ok',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateResponse(response);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('statusCode must be a number');
|
||||||
|
expect(result.errors).toContain('errcode must be a number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该检测无效的响应结构', () => {
|
||||||
|
const response = null;
|
||||||
|
|
||||||
|
const result = validateResponse(response);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Response is null or undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该检测缺少 data 字段', () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
// 缺少 data
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateResponse(response);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Missing required field: data');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 getDingTalkErrorDescription 函数', () => {
|
||||||
|
it('应该返回已知错误码的描述', () => {
|
||||||
|
expect(getDingTalkErrorDescription(310000)).toBe('无效的 webhook URL 或访问令牌');
|
||||||
|
expect(getDingTalkErrorDescription(310001)).toBe('无效签名');
|
||||||
|
expect(getDingTalkErrorDescription(310002)).toBe('无效时间戳');
|
||||||
|
expect(getDingTalkErrorDescription(310003)).toBe('无效请求格式');
|
||||||
|
expect(getDingTalkErrorDescription(310004)).toBe('消息内容过长');
|
||||||
|
expect(getDingTalkErrorDescription(310005)).toBe('消息发送频率超限');
|
||||||
|
expect(getDingTalkErrorDescription(-1)).toBe('系统繁忙,请稍后再试');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回未知错误码的默认描述', () => {
|
||||||
|
expect(getDingTalkErrorDescription(999999)).toBe('未知错误');
|
||||||
|
expect(getDingTalkErrorDescription(0)).toBe('请求成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理非数字错误码', () => {
|
||||||
|
expect(getDingTalkErrorDescription('invalid')).toBe('未知错误');
|
||||||
|
expect(getDingTalkErrorDescription(null)).toBe('未知错误');
|
||||||
|
expect(getDingTalkErrorDescription(undefined)).toBe('未知错误');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 createRequestSummary 函数', () => {
|
||||||
|
it('应该为成功响应创建摘要', () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: {
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok',
|
||||||
|
message_id: 'msg123456',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
duration: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = createRequestSummary(response);
|
||||||
|
|
||||||
|
expect(summary).toContain('Status: 200');
|
||||||
|
expect(summary).toContain('Duration: 150ms');
|
||||||
|
expect(summary).toContain('DingTalk Code: 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该为失败响应创建摘要', () => {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
statusCode: 400,
|
||||||
|
data: {
|
||||||
|
errcode: 310000,
|
||||||
|
errmsg: 'invalid token',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
duration: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = createRequestSummary(response);
|
||||||
|
|
||||||
|
expect(summary).toContain('Status: 400');
|
||||||
|
expect(summary).toContain('Duration: 200ms');
|
||||||
|
expect(summary).toContain('DingTalk Code: 310000');
|
||||||
|
expect(summary).toContain('无效的 webhook URL 或访问令牌');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该为网络错误创建摘要', () => {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
statusCode: 0,
|
||||||
|
data: {},
|
||||||
|
error: 'ECONNREFUSED',
|
||||||
|
duration: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = createRequestSummary(response);
|
||||||
|
|
||||||
|
expect(summary).toContain('Status: 0');
|
||||||
|
expect(summary).toContain('Duration: 5000ms');
|
||||||
|
expect(summary).toContain('Error: ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理缺少消息ID的成功响应', () => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: {
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok',
|
||||||
|
// 缺少 message_id
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
duration: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = createRequestSummary(response);
|
||||||
|
|
||||||
|
expect(summary).toContain('Status: 200');
|
||||||
|
expect(summary).toContain('Duration: 100ms');
|
||||||
|
expect(summary).toContain('DingTalk Code: 0');
|
||||||
|
expect(summary).not.toContain('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空的错误消息', () => {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
statusCode: 400,
|
||||||
|
data: {
|
||||||
|
errcode: 300001,
|
||||||
|
errmsg: '',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
duration: 250,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = createRequestSummary(response);
|
||||||
|
|
||||||
|
expect(summary).toContain('Status: 400');
|
||||||
|
expect(summary).toContain('Duration: 250ms');
|
||||||
|
expect(summary).toContain('DingTalk Code: 300001');
|
||||||
|
expect(summary).toContain('未知错误');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界条件测试', () => {
|
||||||
|
const testUrl = 'https://oapi.dingtalk.com/robot/send?access_token=test';
|
||||||
|
const testMessage = { msgtype: 'text', text: { content: 'test' } };
|
||||||
|
|
||||||
|
it('应该处理极大的响应数据', async () => {
|
||||||
|
const largeData = {
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok',
|
||||||
|
message_id: 'msg123456',
|
||||||
|
large_field: 'x'.repeat(10000), // 10KB 的数据
|
||||||
|
};
|
||||||
|
|
||||||
|
await setupHttpMock({
|
||||||
|
statusCode: 200,
|
||||||
|
data: largeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.large_field).toBe('x'.repeat(10000));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理特殊字符的响应', async () => {
|
||||||
|
const specialData = {
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok with 特殊字符 and émojis 🎉',
|
||||||
|
message_id: 'msg123456',
|
||||||
|
};
|
||||||
|
|
||||||
|
await setupHttpMock({
|
||||||
|
statusCode: 200,
|
||||||
|
data: specialData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.errmsg).toBe('ok with 特殊字符 and émojis 🎉');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理畸形的 JSON 响应', async () => {
|
||||||
|
await setupHttpMock(dingTalkResponses.special.malformedJson);
|
||||||
|
|
||||||
|
const result = await sendRequest(testUrl, testMessage);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(typeof result.error).toBe('string');
|
||||||
|
expect(result.error).toContain('JSON parse error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
939
tests/unit/message.test.js
Normal file
939
tests/unit/message.test.js
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
/**
|
||||||
|
* message.js 文件单元测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MESSAGE_TYPES } from '../../src/config.js';
|
||||||
|
import {
|
||||||
|
buildActionCardMessage,
|
||||||
|
buildFeedCardMessage,
|
||||||
|
buildLinkMessage,
|
||||||
|
buildMarkdownMessage,
|
||||||
|
buildMessage,
|
||||||
|
buildTextMessage,
|
||||||
|
getMessageSummary,
|
||||||
|
validateMessage,
|
||||||
|
} from '../../src/message.js';
|
||||||
|
import { assertDingTalkMessage } from '../helpers/assertions.js';
|
||||||
|
|
||||||
|
describe('测试 message.js 文件', () => {
|
||||||
|
describe('测试 buildTextMessage 函数', () => {
|
||||||
|
it('应该构建基本的文本消息', () => {
|
||||||
|
const message = buildTextMessage('Hello, World!');
|
||||||
|
|
||||||
|
assertDingTalkMessage(message, MESSAGE_TYPES.TEXT);
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||||
|
expect(message.text.content).toBe('Hello, World!');
|
||||||
|
expect(message.at).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建包含@手机号的文本消息', () => {
|
||||||
|
const atMobiles = ['13800138000', '13900139000'];
|
||||||
|
const message = buildTextMessage('Hello, World!', atMobiles);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||||
|
expect(message.text.content).toBe('Hello, World!');
|
||||||
|
expect(message.at).toEqual({
|
||||||
|
atMobiles: ['13800138000', '13900139000'],
|
||||||
|
isAtAll: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建@所有人的文本消息', () => {
|
||||||
|
const message = buildTextMessage('Hello, World!', [], true);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||||
|
expect(message.text.content).toBe('Hello, World!');
|
||||||
|
expect(message.at).toEqual({
|
||||||
|
atMobiles: [],
|
||||||
|
isAtAll: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建同时包含@手机号和@所有人的文本消息', () => {
|
||||||
|
const atMobiles = ['13800138000'];
|
||||||
|
const message = buildTextMessage('Hello, World!', atMobiles, true);
|
||||||
|
|
||||||
|
expect(message.at).toEqual({
|
||||||
|
atMobiles: ['13800138000'],
|
||||||
|
isAtAll: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少内容抛出错误', () => {
|
||||||
|
expect(() => buildTextMessage()).toThrow('Content is required for text message');
|
||||||
|
expect(() => buildTextMessage('')).toThrow('Content is required for text message');
|
||||||
|
expect(() => buildTextMessage(null)).toThrow('Content is required for text message');
|
||||||
|
expect(() => buildTextMessage(123)).toThrow('Content is required for text message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空的@手机号数组', () => {
|
||||||
|
const message = buildTextMessage('Hello, World!', []);
|
||||||
|
|
||||||
|
expect(message.at).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 null/undefined 的@手机号', () => {
|
||||||
|
const message1 = buildTextMessage('Hello, World!', null);
|
||||||
|
const message2 = buildTextMessage('Hello, World!', undefined);
|
||||||
|
|
||||||
|
expect(message1.at).toBeUndefined();
|
||||||
|
expect(message2.at).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 buildMarkdownMessage 函数', () => {
|
||||||
|
it('应该构建基本的 Markdown 消息', () => {
|
||||||
|
const message = buildMarkdownMessage('标题', '## Markdown 内容');
|
||||||
|
|
||||||
|
assertDingTalkMessage(message, MESSAGE_TYPES.MARKDOWN);
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||||
|
expect(message.markdown.title).toBe('标题');
|
||||||
|
expect(message.markdown.text).toBe('## Markdown 内容');
|
||||||
|
expect(message.at).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建包含@手机号的 Markdown 消息', () => {
|
||||||
|
const atMobiles = ['13800138000', '13900139000'];
|
||||||
|
const message = buildMarkdownMessage('标题', '## Markdown 内容', atMobiles);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||||
|
expect(message.markdown.title).toBe('标题');
|
||||||
|
expect(message.markdown.text).toBe('## Markdown 内容\n\n@13800138000 @13900139000');
|
||||||
|
expect(message.at).toEqual({
|
||||||
|
atMobiles: ['13800138000', '13900139000'],
|
||||||
|
isAtAll: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建@所有人的 Markdown 消息', () => {
|
||||||
|
const message = buildMarkdownMessage('标题', '## Markdown 内容', [], true);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||||
|
expect(message.markdown.text).toBe('## Markdown 内容\n\n@所有人');
|
||||||
|
expect(message.at).toEqual({
|
||||||
|
atMobiles: [],
|
||||||
|
isAtAll: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建同时包含@手机号和@所有人的 Markdown 消息', () => {
|
||||||
|
const atMobiles = ['13800138000'];
|
||||||
|
const message = buildMarkdownMessage('标题', '## Markdown 内容', atMobiles, true);
|
||||||
|
|
||||||
|
expect(message.markdown.text).toBe('## Markdown 内容\n\n@13800138000\n\n@所有人');
|
||||||
|
expect(message.at).toEqual({
|
||||||
|
atMobiles: ['13800138000'],
|
||||||
|
isAtAll: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少标题抛出错误', () => {
|
||||||
|
expect(() => buildMarkdownMessage()).toThrow('Title is required for markdown message');
|
||||||
|
expect(() => buildMarkdownMessage('')).toThrow('Title is required for markdown message');
|
||||||
|
expect(() => buildMarkdownMessage(null)).toThrow('Title is required for markdown message');
|
||||||
|
expect(() => buildMarkdownMessage(123)).toThrow('Title is required for markdown message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少内容抛出错误', () => {
|
||||||
|
expect(() => buildMarkdownMessage('标题')).toThrow(
|
||||||
|
'Content is required for markdown message',
|
||||||
|
);
|
||||||
|
expect(() => buildMarkdownMessage('标题', '')).toThrow(
|
||||||
|
'Content is required for markdown message',
|
||||||
|
);
|
||||||
|
expect(() => buildMarkdownMessage('标题', null)).toThrow(
|
||||||
|
'Content is required for markdown message',
|
||||||
|
);
|
||||||
|
expect(() => buildMarkdownMessage('标题', 123)).toThrow(
|
||||||
|
'Content is required for markdown message',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 buildLinkMessage 函数', () => {
|
||||||
|
it('应该构建基本的链接消息', () => {
|
||||||
|
const message = buildLinkMessage('链接标题', '链接描述', 'https://example.com');
|
||||||
|
|
||||||
|
assertDingTalkMessage(message, MESSAGE_TYPES.LINK);
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.LINK);
|
||||||
|
expect(message.link.title).toBe('链接标题');
|
||||||
|
expect(message.link.text).toBe('链接描述');
|
||||||
|
expect(message.link.messageUrl).toBe('https://example.com');
|
||||||
|
expect(message.link.picUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建包含图片 URL 的链接消息', () => {
|
||||||
|
const message = buildLinkMessage(
|
||||||
|
'链接标题',
|
||||||
|
'链接描述',
|
||||||
|
'https://example.com',
|
||||||
|
'https://example.com/image.jpg',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(message.link.picUrl).toBe('https://example.com/image.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该忽略无效的图片 URL', () => {
|
||||||
|
const message = buildLinkMessage(
|
||||||
|
'链接标题',
|
||||||
|
'链接描述',
|
||||||
|
'https://example.com',
|
||||||
|
'not-a-valid-url',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(message.link.picUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该忽略非字符串的图片 URL', () => {
|
||||||
|
const message = buildLinkMessage('链接标题', '链接描述', 'https://example.com', 123);
|
||||||
|
|
||||||
|
expect(message.link.picUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少标题抛出错误', () => {
|
||||||
|
expect(() => buildLinkMessage()).toThrow('Title is required for link message');
|
||||||
|
expect(() => buildLinkMessage('')).toThrow('Title is required for link message');
|
||||||
|
expect(() => buildLinkMessage(null)).toThrow('Title is required for link message');
|
||||||
|
expect(() => buildLinkMessage(123)).toThrow('Title is required for link message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少内容抛出错误', () => {
|
||||||
|
expect(() => buildLinkMessage('标题')).toThrow('Content is required for link message');
|
||||||
|
expect(() => buildLinkMessage('标题', '')).toThrow('Content is required for link message');
|
||||||
|
expect(() => buildLinkMessage('标题', null)).toThrow('Content is required for link message');
|
||||||
|
expect(() => buildLinkMessage('标题', 123)).toThrow('Content is required for link message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少链接 URL 抛出错误', () => {
|
||||||
|
expect(() => buildLinkMessage('标题', '内容')).toThrow(
|
||||||
|
'Link URL is required for link message',
|
||||||
|
);
|
||||||
|
expect(() => buildLinkMessage('标题', '内容', '')).toThrow(
|
||||||
|
'Link URL is required for link message',
|
||||||
|
);
|
||||||
|
expect(() => buildLinkMessage('标题', '内容', null)).toThrow(
|
||||||
|
'Link URL is required for link message',
|
||||||
|
);
|
||||||
|
expect(() => buildLinkMessage('标题', '内容', 123)).toThrow(
|
||||||
|
'Link URL is required for link message',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对无效的链接 URL 格式抛出错误', () => {
|
||||||
|
expect(() => buildLinkMessage('标题', '内容', 'not-a-url')).toThrow(
|
||||||
|
'Link URL must be a valid HTTP/HTTPS URL',
|
||||||
|
);
|
||||||
|
expect(() => buildLinkMessage('标题', '内容', 'ftp://example.com')).toThrow(
|
||||||
|
'Link URL must be a valid HTTP/HTTPS URL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该接受 HTTP 和 HTTPS URL', () => {
|
||||||
|
const httpMessage = buildLinkMessage('标题', '内容', 'http://example.com');
|
||||||
|
const httpsMessage = buildLinkMessage('标题', '内容', 'https://example.com');
|
||||||
|
|
||||||
|
expect(httpMessage.link.messageUrl).toBe('http://example.com');
|
||||||
|
expect(httpsMessage.link.messageUrl).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 buildActionCardMessage 函数', () => {
|
||||||
|
it('应该构建单按钮 ActionCard 消息', () => {
|
||||||
|
const options = {
|
||||||
|
btnOrientation: '0',
|
||||||
|
singleTitle: '查看详情',
|
||||||
|
singleURL: 'https://example.com/detail',
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options);
|
||||||
|
|
||||||
|
assertDingTalkMessage(message, MESSAGE_TYPES.ACTION_CARD);
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD);
|
||||||
|
expect(message.actionCard.title).toBe('ActionCard 标题');
|
||||||
|
expect(message.actionCard.text).toBe('ActionCard 内容');
|
||||||
|
expect(message.actionCard.btnOrientation).toBe('0');
|
||||||
|
expect(message.actionCard.singleTitle).toBe('查看详情');
|
||||||
|
expect(message.actionCard.singleURL).toBe('https://example.com/detail');
|
||||||
|
expect(message.actionCard.btns).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建多按钮 ActionCard 消息', () => {
|
||||||
|
const options = {
|
||||||
|
btnOrientation: '1',
|
||||||
|
buttons: [
|
||||||
|
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||||
|
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD);
|
||||||
|
expect(message.actionCard.btnOrientation).toBe('1');
|
||||||
|
expect(message.actionCard.btns).toEqual([
|
||||||
|
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||||
|
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||||
|
]);
|
||||||
|
expect(message.actionCard.singleTitle).toBeUndefined();
|
||||||
|
expect(message.actionCard.singleURL).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用默认的按钮方向', () => {
|
||||||
|
const options = {
|
||||||
|
singleTitle: '查看详情',
|
||||||
|
singleURL: 'https://example.com/detail',
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options);
|
||||||
|
|
||||||
|
expect(message.actionCard.btnOrientation).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空选项', () => {
|
||||||
|
expect(() => buildActionCardMessage('标题', '内容', {})).toThrow(
|
||||||
|
'actionCard requires either singleTitle/singleURL or buttons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理未定义的选项', () => {
|
||||||
|
expect(() => buildActionCardMessage('标题', '内容')).toThrow(
|
||||||
|
'actionCard requires either singleTitle/singleURL or buttons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少标题抛出错误', () => {
|
||||||
|
expect(() => buildActionCardMessage()).toThrow('Title is required for actionCard message');
|
||||||
|
expect(() => buildActionCardMessage('')).toThrow('Title is required for actionCard message');
|
||||||
|
expect(() => buildActionCardMessage(null)).toThrow(
|
||||||
|
'Title is required for actionCard message',
|
||||||
|
);
|
||||||
|
expect(() => buildActionCardMessage(123)).toThrow('Title is required for actionCard message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少内容抛出错误', () => {
|
||||||
|
expect(() => buildActionCardMessage('标题')).toThrow(
|
||||||
|
'Content is required for actionCard message',
|
||||||
|
);
|
||||||
|
expect(() => buildActionCardMessage('标题', '')).toThrow(
|
||||||
|
'Content is required for actionCard message',
|
||||||
|
);
|
||||||
|
expect(() => buildActionCardMessage('标题', null)).toThrow(
|
||||||
|
'Content is required for actionCard message',
|
||||||
|
);
|
||||||
|
expect(() => buildActionCardMessage('标题', 123)).toThrow(
|
||||||
|
'Content is required for actionCard message',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对既没有单按钮也没有按钮数组的情况抛出错误', () => {
|
||||||
|
expect(() => buildActionCardMessage('标题', '内容', {})).toThrow(
|
||||||
|
'actionCard requires either singleTitle/singleURL or buttons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空的按钮数组', () => {
|
||||||
|
const options = { buttons: [] };
|
||||||
|
|
||||||
|
expect(() => buildActionCardMessage('标题', '内容', options)).toThrow(
|
||||||
|
'actionCard requires either singleTitle/singleURL or buttons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理非数组的按钮', () => {
|
||||||
|
const options = { buttons: 'not-an-array' };
|
||||||
|
|
||||||
|
expect(() => buildActionCardMessage('标题', '内容', options)).toThrow(
|
||||||
|
'actionCard requires either singleTitle/singleURL or buttons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当同时提供单按钮和按钮数组时应该优先使用单按钮', () => {
|
||||||
|
const options = {
|
||||||
|
singleTitle: '单按钮',
|
||||||
|
singleURL: 'https://example.com/single',
|
||||||
|
buttons: [{ title: '多按钮', actionURL: 'https://example.com/multi' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildActionCardMessage('标题', '内容', options);
|
||||||
|
|
||||||
|
expect(message.actionCard.singleTitle).toBe('单按钮');
|
||||||
|
expect(message.actionCard.singleURL).toBe('https://example.com/single');
|
||||||
|
expect(message.actionCard.btns).toEqual([
|
||||||
|
{ title: '多按钮', actionURL: 'https://example.com/multi' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 buildFeedCardMessage 函数', () => {
|
||||||
|
it('应该构建 FeedCard 消息', () => {
|
||||||
|
const links = [
|
||||||
|
{
|
||||||
|
title: '新闻1',
|
||||||
|
messageURL: 'https://example.com/news1',
|
||||||
|
picURL: 'https://example.com/pic1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '新闻2',
|
||||||
|
messageURL: 'https://example.com/news2',
|
||||||
|
picURL: 'https://example.com/pic2.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const message = buildFeedCardMessage(links);
|
||||||
|
|
||||||
|
assertDingTalkMessage(message, MESSAGE_TYPES.FEED_CARD);
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.FEED_CARD);
|
||||||
|
expect(message.feedCard.links).toEqual(links);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该构建包含单个链接的 FeedCard 消息', () => {
|
||||||
|
const links = [
|
||||||
|
{
|
||||||
|
title: '单个新闻',
|
||||||
|
messageURL: 'https://example.com/news',
|
||||||
|
picURL: 'https://example.com/pic.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const message = buildFeedCardMessage(links);
|
||||||
|
|
||||||
|
expect(message.feedCard.links).toHaveLength(1);
|
||||||
|
expect(message.feedCard.links[0]).toEqual(links[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对缺少链接抛出错误', () => {
|
||||||
|
expect(() => buildFeedCardMessage()).toThrow('FeedCard requires a non-empty links array');
|
||||||
|
expect(() => buildFeedCardMessage(null)).toThrow('FeedCard requires a non-empty links array');
|
||||||
|
expect(() => buildFeedCardMessage('not-an-array')).toThrow(
|
||||||
|
'FeedCard requires a non-empty links array',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对空链接数组抛出错误', () => {
|
||||||
|
expect(() => buildFeedCardMessage([])).toThrow('FeedCard requires a non-empty links array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确映射链接属性', () => {
|
||||||
|
const links = [
|
||||||
|
{
|
||||||
|
title: '测试标题',
|
||||||
|
messageURL: 'https://test.com',
|
||||||
|
picURL: 'https://test.com/pic.jpg',
|
||||||
|
extraProperty: 'should be ignored',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const message = buildFeedCardMessage(links);
|
||||||
|
|
||||||
|
expect(message.feedCard.links[0]).toEqual({
|
||||||
|
title: '测试标题',
|
||||||
|
messageURL: 'https://test.com',
|
||||||
|
picURL: 'https://test.com/pic.jpg',
|
||||||
|
});
|
||||||
|
expect(message.feedCard.links[0].extraProperty).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 buildMessage 函数', () => {
|
||||||
|
it('应该根据配置构建文本消息', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'text',
|
||||||
|
content: 'Hello, World!',
|
||||||
|
atMobiles: ['13800138000'],
|
||||||
|
atAll: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildMessage(config);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT);
|
||||||
|
expect(message.text.content).toBe('Hello, World!');
|
||||||
|
expect(message.at.atMobiles).toEqual(['13800138000']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该根据配置构建 Markdown 消息', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'markdown',
|
||||||
|
title: 'Markdown 标题',
|
||||||
|
content: '## Markdown 内容',
|
||||||
|
atMobiles: [],
|
||||||
|
atAll: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildMessage(config);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN);
|
||||||
|
expect(message.markdown.title).toBe('Markdown 标题');
|
||||||
|
expect(message.at.isAtAll).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该根据配置构建链接消息', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'link',
|
||||||
|
title: '链接标题',
|
||||||
|
content: '链接描述',
|
||||||
|
linkUrl: 'https://example.com',
|
||||||
|
picUrl: 'https://example.com/pic.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildMessage(config);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.LINK);
|
||||||
|
expect(message.link.title).toBe('链接标题');
|
||||||
|
expect(message.link.messageUrl).toBe('https://example.com');
|
||||||
|
expect(message.link.picUrl).toBe('https://example.com/pic.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该根据配置构建 ActionCard 消息', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'actionCard',
|
||||||
|
title: 'ActionCard 标题',
|
||||||
|
content: 'ActionCard 内容',
|
||||||
|
btnOrientation: '1',
|
||||||
|
buttons: [{ title: '按钮1', actionURL: 'https://example.com/action1' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildMessage(config);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD);
|
||||||
|
expect(message.actionCard.title).toBe('ActionCard 标题');
|
||||||
|
expect(message.actionCard.btnOrientation).toBe('1');
|
||||||
|
expect(message.actionCard.btns).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该根据配置构建 FeedCard 消息', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'feedCard',
|
||||||
|
feedLinks: [
|
||||||
|
{
|
||||||
|
title: '新闻1',
|
||||||
|
messageURL: 'https://example.com/news1',
|
||||||
|
picURL: 'https://example.com/pic1.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = buildMessage(config);
|
||||||
|
|
||||||
|
expect(message.msgtype).toBe(MESSAGE_TYPES.FEED_CARD);
|
||||||
|
expect(message.feedCard.links).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对不支持的消息类型抛出错误', () => {
|
||||||
|
const config = {
|
||||||
|
messageType: 'unsupported',
|
||||||
|
content: 'Test content',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => buildMessage(config)).toThrow('Unsupported message type: unsupported');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 validateMessage 函数', () => {
|
||||||
|
it('应该验证有效的文本消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: 'Hello, World!',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该验证有效的 Markdown 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: {
|
||||||
|
title: '标题',
|
||||||
|
text: '## 内容',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该验证有效的链接消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'link',
|
||||||
|
link: {
|
||||||
|
title: '链接标题',
|
||||||
|
text: '链接描述',
|
||||||
|
messageUrl: 'https://example.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该验证有效的单按钮 ActionCard 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'actionCard',
|
||||||
|
actionCard: {
|
||||||
|
title: 'ActionCard 标题',
|
||||||
|
text: 'ActionCard 内容',
|
||||||
|
singleTitle: '查看详情',
|
||||||
|
singleURL: 'https://example.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该验证有效的多按钮 ActionCard 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'actionCard',
|
||||||
|
actionCard: {
|
||||||
|
title: 'ActionCard 标题',
|
||||||
|
text: 'ActionCard 内容',
|
||||||
|
btns: [
|
||||||
|
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||||
|
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该验证有效的 FeedCard 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'feedCard',
|
||||||
|
feedCard: {
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: '新闻1',
|
||||||
|
messageURL: 'https://example.com/news1',
|
||||||
|
picURL: 'https://example.com/pic1.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝 null/undefined 消息', () => {
|
||||||
|
expect(validateMessage(null).isValid).toBe(false);
|
||||||
|
expect(validateMessage(undefined).isValid).toBe(false);
|
||||||
|
expect(validateMessage('not-an-object').isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝没有 msgtype 的消息', () => {
|
||||||
|
const message = {
|
||||||
|
text: { content: 'Hello' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Invalid or missing msgtype');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝带有无效 msgtype 的消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'invalid',
|
||||||
|
text: { content: 'Hello' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Invalid or missing msgtype');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝没有内容的文本消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Text message must have content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝没有标题或内容的 Markdown 消息', () => {
|
||||||
|
const message1 = {
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: { text: '内容' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const message2 = {
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: { title: '标题' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateMessage(message1).isValid).toBe(false);
|
||||||
|
expect(validateMessage(message2).isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝缺少字段的链接消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'link',
|
||||||
|
link: { title: '标题' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Link message must have title, text, and messageUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝单按钮不完整的 ActionCard 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'actionCard',
|
||||||
|
actionCard: {
|
||||||
|
title: '标题',
|
||||||
|
text: '内容',
|
||||||
|
singleTitle: '按钮',
|
||||||
|
// missing singleURL
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain(
|
||||||
|
'ActionCard single button requires both singleTitle and singleURL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝带有无效按钮的 ActionCard 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'actionCard',
|
||||||
|
actionCard: {
|
||||||
|
title: '标题',
|
||||||
|
text: '内容',
|
||||||
|
btns: [
|
||||||
|
{ title: '按钮1' }, // missing actionURL
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Each ActionCard button must have title and actionURL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝没有链接的 FeedCard 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'feedCard',
|
||||||
|
feedCard: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('FeedCard must have a non-empty links array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝带有无效链接的 FeedCard 消息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'feedCard',
|
||||||
|
feedCard: {
|
||||||
|
links: [
|
||||||
|
{ title: '新闻1' }, // missing messageURL and picURL
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateMessage(message);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Each FeedCard link must have title, messageURL and picURL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 getMessageSummary 函数', () => {
|
||||||
|
it('应该返回文本消息的摘要', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: 'Hello, World!',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: text, Content: "Hello, World!"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该截断长文本内容', () => {
|
||||||
|
const longContent = 'A'.repeat(60);
|
||||||
|
const message = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: longContent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toContain(`Type: text, Content: "${'A'.repeat(50)}..."`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回 Markdown 消息的摘要', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: {
|
||||||
|
title: 'Markdown 标题',
|
||||||
|
text: '## Markdown 内容',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: markdown, Title: "Markdown 标题", Text: "## Markdown 内容"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回链接消息的摘要', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'link',
|
||||||
|
link: {
|
||||||
|
title: '链接标题',
|
||||||
|
text: '链接描述',
|
||||||
|
messageUrl: 'https://example.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: link, Title: "链接标题", URL: "https://example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回单按钮 ActionCard 消息的摘要', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'actionCard',
|
||||||
|
actionCard: {
|
||||||
|
title: 'ActionCard 标题',
|
||||||
|
text: 'ActionCard 内容',
|
||||||
|
singleTitle: '查看详情',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: actionCard, Title: "ActionCard 标题", Single: "查看详情"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回多按钮 ActionCard 消息的摘要', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'actionCard',
|
||||||
|
actionCard: {
|
||||||
|
title: 'ActionCard 标题',
|
||||||
|
text: 'ActionCard 内容',
|
||||||
|
btns: [
|
||||||
|
{ title: '按钮1', actionURL: 'https://example.com/action1' },
|
||||||
|
{ title: '按钮2', actionURL: 'https://example.com/action2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: actionCard, Title: "ActionCard 标题", Buttons: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回 FeedCard 消息的摘要', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'feedCard',
|
||||||
|
feedCard: {
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: '新闻1',
|
||||||
|
messageURL: 'https://example.com/news1',
|
||||||
|
picURL: 'https://example.com/pic1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '新闻2',
|
||||||
|
messageURL: 'https://example.com/news2',
|
||||||
|
picURL: 'https://example.com/pic2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: feedCard, Links: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该包含 @所有人 信息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: 'Hello, World!',
|
||||||
|
},
|
||||||
|
at: {
|
||||||
|
isAtAll: true,
|
||||||
|
atMobiles: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: text, Content: "Hello, World!", @All: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该包含 @手机号 信息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: 'Hello, World!',
|
||||||
|
},
|
||||||
|
at: {
|
||||||
|
isAtAll: false,
|
||||||
|
atMobiles: ['13800138000', '13900139000'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: text, Content: "Hello, World!", @Mobiles: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该同时包含 @所有人 和 @手机号 信息', () => {
|
||||||
|
const message = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: 'Hello, World!',
|
||||||
|
},
|
||||||
|
at: {
|
||||||
|
isAtAll: true,
|
||||||
|
atMobiles: ['13800138000'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = getMessageSummary(message);
|
||||||
|
|
||||||
|
expect(summary).toBe('Type: text, Content: "Hello, World!", @All: true, @Mobiles: 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理无效的消息', () => {
|
||||||
|
expect(getMessageSummary(null)).toBe('Invalid message');
|
||||||
|
expect(getMessageSummary({})).toBe('Invalid message');
|
||||||
|
expect(getMessageSummary({ msgtype: null })).toBe('Invalid message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
573
tests/unit/utils.test.js
Normal file
573
tests/unit/utils.test.js
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
/**
|
||||||
|
* utils.js 文件单元测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as utils from '../../src/utils.js';
|
||||||
|
import { assertError } from '../helpers/assertions.js';
|
||||||
|
import { mockEnvVars } from '../helpers/env-mock.js';
|
||||||
|
|
||||||
|
describe('测试 utils.js 文件', () => {
|
||||||
|
let restoreEnv;
|
||||||
|
let consoleSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock console methods
|
||||||
|
consoleSpy = {
|
||||||
|
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||||
|
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||||
|
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore console methods
|
||||||
|
Object.values(consoleSpy).forEach(spy => spy.mockRestore());
|
||||||
|
|
||||||
|
// Restore environment variables
|
||||||
|
if (restoreEnv) {
|
||||||
|
restoreEnv();
|
||||||
|
restoreEnv = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 LOG_LEVELS 常量', () => {
|
||||||
|
it('应该导出正确的日志级别', () => {
|
||||||
|
expect(utils.LOG_LEVELS).toEqual({
|
||||||
|
DEBUG: 'DEBUG',
|
||||||
|
INFO: 'INFO',
|
||||||
|
WARN: 'WARN',
|
||||||
|
ERROR: 'ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 getTimestamp 函数', () => {
|
||||||
|
it('应该返回 ISO 字符串时间戳', () => {
|
||||||
|
const timestamp = utils.getTimestamp();
|
||||||
|
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||||
|
expect(() => new Date(timestamp)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该为连续调用返回不同的时间戳', async () => {
|
||||||
|
const timestamp1 = utils.getTimestamp();
|
||||||
|
// Add a small delay to ensure different timestamps
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
const timestamp2 = utils.getTimestamp();
|
||||||
|
expect(timestamp1).not.toBe(timestamp2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 formatLogMessage 函数', () => {
|
||||||
|
it('应该使用级别和消息格式化日志消息', () => {
|
||||||
|
const message = utils.formatLogMessage('INFO', 'Test message');
|
||||||
|
expect(message).toMatch(
|
||||||
|
/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] Test message$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在提供元数据时包含它', () => {
|
||||||
|
const meta = { key: 'value', number: 123 };
|
||||||
|
const message = utils.formatLogMessage('DEBUG', 'Test message', meta);
|
||||||
|
expect(message).toContain('Test message | {"key":"value","number":123}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 null 元数据', () => {
|
||||||
|
const message = utils.formatLogMessage('WARN', 'Test message', null);
|
||||||
|
expect(message).not.toContain('|');
|
||||||
|
expect(message).toContain('Test message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 undefined 元数据', () => {
|
||||||
|
const message = utils.formatLogMessage('ERROR', 'Test message');
|
||||||
|
expect(message).not.toContain('|');
|
||||||
|
expect(message).toContain('Test message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 logDebug 函数', () => {
|
||||||
|
it('应该在 DEBUG=true 时记录调试消息', () => {
|
||||||
|
restoreEnv = mockEnvVars({ DEBUG: 'true' });
|
||||||
|
|
||||||
|
utils.logDebug('Debug message', { test: true });
|
||||||
|
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[DEBUG] Debug message | {"test":true}'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在 NODE_ENV=development 时记录调试消息', () => {
|
||||||
|
restoreEnv = mockEnvVars({ NODE_ENV: 'development' });
|
||||||
|
|
||||||
|
utils.logDebug('Debug message');
|
||||||
|
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[DEBUG] Debug message'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不应该在生产环境中记录调试消息', () => {
|
||||||
|
restoreEnv = mockEnvVars({ DEBUG: 'false', NODE_ENV: 'production' });
|
||||||
|
|
||||||
|
utils.logDebug('Debug message');
|
||||||
|
|
||||||
|
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 logInfo 函数', () => {
|
||||||
|
it('应该始终记录信息消息', () => {
|
||||||
|
utils.logInfo('Info message', { data: 'test' });
|
||||||
|
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[INFO] Info message | {"data":"test"}'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 logWarn 函数', () => {
|
||||||
|
it('应该始终记录警告消息', () => {
|
||||||
|
utils.logWarn('Warning message');
|
||||||
|
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[WARN] Warning message'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 logError 函数', () => {
|
||||||
|
it('应该始终记录错误消息', () => {
|
||||||
|
utils.logError('Error message', { error: 'details' });
|
||||||
|
|
||||||
|
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[ERROR] Error message | {"error":"details"}'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 safeJsonParse 函数', () => {
|
||||||
|
it('应该解析有效的 JSON 字符串', () => {
|
||||||
|
const obj = { key: 'value', number: 123 };
|
||||||
|
const jsonString = JSON.stringify(obj);
|
||||||
|
const result = utils.safeJsonParse(jsonString);
|
||||||
|
|
||||||
|
expect(result).toEqual(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该为无效的 JSON 返回默认值', () => {
|
||||||
|
const result = utils.safeJsonParse('invalid json', { default: true });
|
||||||
|
|
||||||
|
expect(result).toEqual({ default: true });
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse JSON'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在未提供默认值时返回 null', () => {
|
||||||
|
const result = utils.safeJsonParse('invalid json');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空字符串', () => {
|
||||||
|
const result = utils.safeJsonParse('', 'default');
|
||||||
|
|
||||||
|
expect(result).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 safeJsonStringify 函数', () => {
|
||||||
|
it('应该序列化有效对象', () => {
|
||||||
|
const obj = { key: 'value', number: 123 };
|
||||||
|
const result = utils.safeJsonStringify(obj);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed).toEqual(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该为循环引用返回默认值', () => {
|
||||||
|
const obj = {};
|
||||||
|
obj.circular = obj;
|
||||||
|
|
||||||
|
const result = utils.safeJsonStringify(obj, '{"error":"circular"}');
|
||||||
|
|
||||||
|
expect(result).toBe('{"error":"circular"}');
|
||||||
|
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Failed to stringify object'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在未提供默认值时返回空对象', () => {
|
||||||
|
const obj = {};
|
||||||
|
obj.circular = obj;
|
||||||
|
|
||||||
|
const result = utils.safeJsonStringify(obj);
|
||||||
|
|
||||||
|
expect(result).toBe('{}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 isEmpty 函数', () => {
|
||||||
|
it('应该对空字符串返回 true', () => {
|
||||||
|
expect(utils.isEmpty('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对仅包含空白的字符串返回 true', () => {
|
||||||
|
expect(utils.isEmpty(' ')).toBe(true);
|
||||||
|
expect(utils.isEmpty('\t\n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对 null 返回 true', () => {
|
||||||
|
expect(utils.isEmpty(null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对 undefined 返回 true', () => {
|
||||||
|
expect(utils.isEmpty(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对非字符串值返回 true', () => {
|
||||||
|
expect(utils.isEmpty(123)).toBe(true);
|
||||||
|
expect(utils.isEmpty({})).toBe(true);
|
||||||
|
expect(utils.isEmpty([])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对非空字符串返回 false', () => {
|
||||||
|
expect(utils.isEmpty('hello')).toBe(false);
|
||||||
|
expect(utils.isEmpty(' hello ')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 truncateString 函数', () => {
|
||||||
|
it('如果字符串短于最大长度应该返回原字符串', () => {
|
||||||
|
const str = 'hello';
|
||||||
|
const result = utils.truncateString(str, 10);
|
||||||
|
|
||||||
|
expect(result).toBe(str);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用默认后缀截断字符串', () => {
|
||||||
|
const str = 'hello world';
|
||||||
|
const result = utils.truncateString(str, 8);
|
||||||
|
|
||||||
|
expect(result).toBe('hello...');
|
||||||
|
expect(result.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用自定义后缀截断字符串', () => {
|
||||||
|
const str = 'hello world';
|
||||||
|
const result = utils.truncateString(str, 10, ' [more]');
|
||||||
|
|
||||||
|
expect(result).toBe('hel [more]');
|
||||||
|
expect(result.length).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理 null 输入', () => {
|
||||||
|
expect(utils.truncateString(null, 10)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理非字符串输入', () => {
|
||||||
|
expect(utils.truncateString(123, 10)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理最大长度等于后缀长度的边界情况', () => {
|
||||||
|
const result = utils.truncateString('hello', 3);
|
||||||
|
expect(result).toBe('...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 cleanMobile 函数', () => {
|
||||||
|
it('应该清理并验证有效的手机号码', () => {
|
||||||
|
expect(utils.cleanMobile('138-0013-8000')).toBe('13800138000');
|
||||||
|
expect(utils.cleanMobile('139 0013 9000')).toBe('13900139000');
|
||||||
|
expect(utils.cleanMobile('(150) 1234-5678')).toBe('15012345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对无效的手机号码返回 null', () => {
|
||||||
|
expect(utils.cleanMobile('12345678901')).toBeNull(); // 不是1开头
|
||||||
|
expect(utils.cleanMobile('1234567890')).toBeNull(); // 位数不够
|
||||||
|
expect(utils.cleanMobile('123456789012')).toBeNull(); // 位数太多
|
||||||
|
expect(utils.cleanMobile('12012345678')).toBeNull(); // 第二位不是3-9
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对非字符串输入返回 null', () => {
|
||||||
|
expect(utils.cleanMobile(null)).toBeNull();
|
||||||
|
expect(utils.cleanMobile(undefined)).toBeNull();
|
||||||
|
expect(utils.cleanMobile(123)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对空字符串返回 null', () => {
|
||||||
|
expect(utils.cleanMobile('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理所有有效的手机号前缀', () => {
|
||||||
|
const validPrefixes = ['13', '14', '15', '16', '17', '18', '19'];
|
||||||
|
validPrefixes.forEach(prefix => {
|
||||||
|
const mobile = `${prefix}012345678`;
|
||||||
|
expect(utils.cleanMobile(mobile)).toBe(mobile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 isValidUrl 函数', () => {
|
||||||
|
it('应该对有效的 URL 返回 true', () => {
|
||||||
|
expect(utils.isValidUrl('https://example.com')).toBe(true);
|
||||||
|
expect(utils.isValidUrl('http://example.com')).toBe(true);
|
||||||
|
expect(utils.isValidUrl('ftp://example.com')).toBe(true);
|
||||||
|
expect(utils.isValidUrl('https://example.com/path?query=value')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对无效的 URL 返回 false', () => {
|
||||||
|
expect(utils.isValidUrl('not-a-url')).toBe(false);
|
||||||
|
expect(utils.isValidUrl('http://')).toBe(false);
|
||||||
|
expect(utils.isValidUrl('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对非字符串输入返回 false', () => {
|
||||||
|
expect(utils.isValidUrl(null)).toBe(false);
|
||||||
|
expect(utils.isValidUrl(undefined)).toBe(false);
|
||||||
|
expect(utils.isValidUrl(123)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 isValidHttpUrl 函数', () => {
|
||||||
|
it('应该对有效的 HTTP/HTTPS URL 返回 true', () => {
|
||||||
|
expect(utils.isValidHttpUrl('https://example.com')).toBe(true);
|
||||||
|
expect(utils.isValidHttpUrl('http://example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对非 HTTP URL 返回 false', () => {
|
||||||
|
expect(utils.isValidHttpUrl('ftp://example.com')).toBe(false);
|
||||||
|
expect(utils.isValidHttpUrl('file:///path/to/file')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对无效的 URL 返回 false', () => {
|
||||||
|
expect(utils.isValidHttpUrl('not-a-url')).toBe(false);
|
||||||
|
expect(utils.isValidHttpUrl('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 createError 函数', () => {
|
||||||
|
it('应该只使用消息创建错误', () => {
|
||||||
|
const error = utils.createError('Test error');
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(error.message).toBe('Test error');
|
||||||
|
expect(error.code).toBeUndefined();
|
||||||
|
expect(error.details).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用消息和代码创建错误', () => {
|
||||||
|
const error = utils.createError('Test error', 'TEST_CODE');
|
||||||
|
|
||||||
|
expect(error.message).toBe('Test error');
|
||||||
|
expect(error.code).toBe('TEST_CODE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用消息、代码和详情创建错误', () => {
|
||||||
|
const details = { key: 'value' };
|
||||||
|
const error = utils.createError('Test error', 'TEST_CODE', details);
|
||||||
|
|
||||||
|
expect(error.message).toBe('Test error');
|
||||||
|
expect(error.code).toBe('TEST_CODE');
|
||||||
|
expect(error.details).toBe(details);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 setActionOutput 函数', () => {
|
||||||
|
it('应该在未设置 GITHUB_OUTPUT 时使用旧格式', () => {
|
||||||
|
restoreEnv = mockEnvVars({ GITHUB_OUTPUT: '' });
|
||||||
|
|
||||||
|
utils.setActionOutput('test_name', 'test_value');
|
||||||
|
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('::set-output name=test_name::test_value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在设置 GITHUB_OUTPUT 时使用新的 GitHub Actions 格式', () => {
|
||||||
|
// Create a temporary file path for testing
|
||||||
|
const tempFile = '/tmp/test_github_output';
|
||||||
|
restoreEnv = mockEnvVars({ GITHUB_OUTPUT: tempFile });
|
||||||
|
|
||||||
|
// Since we can't easily mock require('fs') in this context,
|
||||||
|
// we'll test that the function doesn't throw and doesn't log
|
||||||
|
expect(() => {
|
||||||
|
utils.setActionOutput('test_name', 'test_value');
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
// Verify that console.log was not called (which would indicate legacy format)
|
||||||
|
expect(consoleSpy.log).not.toHaveBeenCalledWith('::set-output name=test_name::test_value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 setActionFailed 函数', () => {
|
||||||
|
it('应该记录错误并退出进程', () => {
|
||||||
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
||||||
|
|
||||||
|
utils.setActionFailed('Test failure');
|
||||||
|
|
||||||
|
expect(consoleSpy.log).toHaveBeenCalledWith('::error::Test failure');
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
mockExit.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 getEnv 函数', () => {
|
||||||
|
it('应该返回环境变量值', () => {
|
||||||
|
restoreEnv = mockEnvVars({ TEST_VAR: 'test_value' });
|
||||||
|
|
||||||
|
const result = utils.getEnv('TEST_VAR');
|
||||||
|
|
||||||
|
expect(result).toBe('test_value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在未设置环境变量时返回默认值', () => {
|
||||||
|
const result = utils.getEnv('NON_EXISTENT_VAR', 'default_value');
|
||||||
|
|
||||||
|
expect(result).toBe('default_value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在未提供默认值时返回空字符串', () => {
|
||||||
|
const result = utils.getEnv('NON_EXISTENT_VAR');
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 isDebugMode 函数', () => {
|
||||||
|
it('应该在 DEBUG=true 时返回 true', () => {
|
||||||
|
restoreEnv = mockEnvVars({ DEBUG: 'true' });
|
||||||
|
|
||||||
|
expect(utils.isDebugMode()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在 NODE_ENV=development 时返回 true', () => {
|
||||||
|
restoreEnv = mockEnvVars({ NODE_ENV: 'development' });
|
||||||
|
|
||||||
|
expect(utils.isDebugMode()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在生产模式中返回 false', () => {
|
||||||
|
restoreEnv = mockEnvVars({ DEBUG: 'false', NODE_ENV: 'production' });
|
||||||
|
|
||||||
|
expect(utils.isDebugMode()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理不区分大小写的 DEBUG 值', () => {
|
||||||
|
restoreEnv = mockEnvVars({ DEBUG: 'TRUE' });
|
||||||
|
|
||||||
|
expect(utils.isDebugMode()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 formatDuration 函数', () => {
|
||||||
|
it('应该格式化毫秒', () => {
|
||||||
|
expect(utils.formatDuration(500)).toBe('500ms');
|
||||||
|
expect(utils.formatDuration(999)).toBe('999ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该格式化秒', () => {
|
||||||
|
expect(utils.formatDuration(1000)).toBe('1.00s');
|
||||||
|
expect(utils.formatDuration(1500)).toBe('1.50s');
|
||||||
|
expect(utils.formatDuration(2345)).toBe('2.35s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理零持续时间', () => {
|
||||||
|
expect(utils.formatDuration(0)).toBe('0ms');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 createRetryConfig 函数', () => {
|
||||||
|
it('应该创建默认重试配置', () => {
|
||||||
|
const config = utils.createRetryConfig();
|
||||||
|
|
||||||
|
expect(config.maxRetries).toBe(3);
|
||||||
|
expect(config.delay).toBe(1000);
|
||||||
|
expect(config.backoffMultiplier).toBe(1.5);
|
||||||
|
expect(typeof config.getDelay).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建自定义重试配置', () => {
|
||||||
|
const config = utils.createRetryConfig(5, 2000, 2);
|
||||||
|
|
||||||
|
expect(config.maxRetries).toBe(5);
|
||||||
|
expect(config.delay).toBe(2000);
|
||||||
|
expect(config.backoffMultiplier).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该为尝试次数计算正确的延迟', () => {
|
||||||
|
const config = utils.createRetryConfig(3, 1000, 2);
|
||||||
|
|
||||||
|
expect(config.getDelay(0)).toBe(1000); // 1000 * 2^0
|
||||||
|
expect(config.getDelay(1)).toBe(2000); // 1000 * 2^1
|
||||||
|
expect(config.getDelay(2)).toBe(4000); // 1000 * 2^2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 maskSensitiveInfo 函数', () => {
|
||||||
|
it('应该在文本中屏蔽敏感信息', () => {
|
||||||
|
const result = utils.maskSensitiveInfo('1234567890', 2, 2);
|
||||||
|
|
||||||
|
expect(result).toBe('12******90');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用自定义屏蔽字符', () => {
|
||||||
|
const result = utils.maskSensitiveInfo('1234567890', 2, 2, 'X');
|
||||||
|
|
||||||
|
expect(result).toBe('12XXXXXX90');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('如果字符串太短应该屏蔽整个字符串', () => {
|
||||||
|
const result = utils.maskSensitiveInfo('123', 2, 2);
|
||||||
|
|
||||||
|
expect(result).toBe('***');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理非字符串输入', () => {
|
||||||
|
expect(utils.maskSensitiveInfo(null)).toBe('');
|
||||||
|
expect(utils.maskSensitiveInfo(123)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空字符串', () => {
|
||||||
|
expect(utils.maskSensitiveInfo('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用默认参数', () => {
|
||||||
|
const result = utils.maskSensitiveInfo('1234567890123456');
|
||||||
|
|
||||||
|
expect(result).toBe('1234********3456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('测试 validateRequiredEnvVars 函数', () => {
|
||||||
|
it('应该在所有必需变量都存在时不抛出异常', () => {
|
||||||
|
restoreEnv = mockEnvVars({
|
||||||
|
VAR1: 'value1',
|
||||||
|
VAR2: 'value2',
|
||||||
|
VAR3: 'value3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
utils.validateRequiredEnvVars(['VAR1', 'VAR2', 'VAR3']);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在缺少必需变量时抛出异常', () => {
|
||||||
|
restoreEnv = mockEnvVars({ VAR1: 'value1' });
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
utils.validateRequiredEnvVars(['VAR1', 'VAR2', 'VAR3']);
|
||||||
|
}).toThrow('Missing required environment variables: VAR2, VAR3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该抛出带有正确代码和详情的错误', () => {
|
||||||
|
try {
|
||||||
|
utils.validateRequiredEnvVars(['MISSING_VAR']);
|
||||||
|
} catch (error) {
|
||||||
|
assertError(error, 'Missing required environment variables', 'MISSING_ENV_VARS');
|
||||||
|
expect(error.details.missing).toEqual(['MISSING_VAR']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空数组', () => {
|
||||||
|
expect(() => {
|
||||||
|
utils.validateRequiredEnvVars([]);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
60
vitest.config.js
Normal file
60
vitest.config.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// 测试环境
|
||||||
|
environment: 'node',
|
||||||
|
|
||||||
|
// 测试文件匹配模式
|
||||||
|
include: ['tests/**/*.test.js'],
|
||||||
|
|
||||||
|
// 排除文件
|
||||||
|
exclude: ['node_modules/**', 'coverage/**'],
|
||||||
|
|
||||||
|
// 全局设置
|
||||||
|
globals: true,
|
||||||
|
|
||||||
|
// 覆盖率配置
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html', 'lcov'],
|
||||||
|
reportsDirectory: 'coverage',
|
||||||
|
include: ['src/**/*.js'],
|
||||||
|
exclude: [
|
||||||
|
'tests/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'coverage/**'
|
||||||
|
],
|
||||||
|
// 覆盖率阈值
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
branches: 70,
|
||||||
|
statements: 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 测试超时设置
|
||||||
|
testTimeout: 10000,
|
||||||
|
|
||||||
|
// 钩子超时设置
|
||||||
|
hookTimeout: 10000,
|
||||||
|
|
||||||
|
// 并发设置
|
||||||
|
pool: 'threads',
|
||||||
|
poolOptions: {
|
||||||
|
threads: {
|
||||||
|
singleThread: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 监听模式配置
|
||||||
|
watch: false,
|
||||||
|
|
||||||
|
// 报告器配置
|
||||||
|
reporter: ['verbose'],
|
||||||
|
|
||||||
|
// 设置文件
|
||||||
|
setupFiles: ['tests/setup/global-setup.js']
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user