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

477 lines
14 KiB
JavaScript

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