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