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

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

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

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

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

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

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

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