/** @jest-environment node */ describe('dingtalk client', () => { const originalEnv = process.env beforeEach(() => { jest.resetModules() process.env = { ...originalEnv } delete process.env.DINGTALK_CLIENT_ID delete process.env.DINGTALK_CLIENT_SECRET delete process.env.DINGTALK_WORK_NOTICE_CLIENT_ID delete process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET delete process.env.DINGTALK_WORK_NOTICE_AGENT_ID delete process.env.DINGTALK_ROBOT_CODE delete process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL delete process.env.DINGTALK_ROBOT_OTO_TEST_USER_IDS delete process.env.YYD_NOTIFICATION_CENTER_TEST_USER_IDS delete process.env.DINGTALK_WORK_NOTICE_CORP_ID delete process.env.DINGTALK_CORP_ID delete process.env.DINGTALK_CORPID delete process.env.NEXT_PUBLIC_DINGTALK_CORP_ID global.fetch = jest.fn() }) afterEach(() => { process.env = originalEnv jest.restoreAllMocks() }) test('工作通知优先使用独立应用凭证获取 token', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_WORK_NOTICE_CLIENT_ID = 'notice-client-id' process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET = 'notice-client-secret' process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '4279191869' ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: 'notice-token', expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, task_id: 123, request_id: 'request-1' }), }) const { sendDingtalkWorkNotice } = await import('@/lib/dingtalk-client') await sendDingtalkWorkNotice({ userIds: ['ding-user-1'], title: '测试通知', text: '这是一条测试通知', }) expect(global.fetch).toHaveBeenNthCalledWith( 1, expect.stringContaining('appkey=notice-client-id'), ) expect(global.fetch).toHaveBeenNthCalledWith( 1, expect.not.stringContaining('appkey=login-client-id'), ) const [, sendOptions] = (global.fetch as jest.Mock).mock.calls[1] expect(JSON.parse(sendOptions.body)).toEqual(expect.objectContaining({ agent_id: 4279191869, userid_list: 'ding-user-1', })) }) test('工作通知未配置独立凭证时仍可复用登录应用凭证', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '123' const { getDingtalkWorkNoticeConfigStatus } = await import('@/lib/dingtalk-client') expect(getDingtalkWorkNoticeConfigStatus()).toEqual(expect.objectContaining({ ready: true, appSource: 'login', })) }) test('工作通知免登入口优先使用工作通知应用 clientId', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_WORK_NOTICE_CLIENT_ID = 'notice-client-id' process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET = 'notice-client-secret' const { getDingtalkWorkNoticeClientId } = await import('@/lib/dingtalk-client') expect(getDingtalkWorkNoticeClientId()).toBe('notice-client-id') }) test('带跳转地址的工作通知优先使用钉钉应用内打开链接', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '4279191869' process.env.DINGTALK_CORP_ID = 'ding-test-corp' ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: 'notice-token', expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, task_id: 123, request_id: 'request-1' }), }) const { sendDingtalkWorkNotice } = await import('@/lib/dingtalk-client') await sendDingtalkWorkNotice({ userIds: ['ding-user-1'], title: '测试通知', text: '这是一条测试通知', actionUrl: 'https://sc.jiansun.vip/dingtalk-entry?redirect=%2Fperformance-survey', actionTitle: '立即填写', }) const [, sendOptions] = (global.fetch as jest.Mock).mock.calls[1] const body = JSON.parse(sendOptions.body) expect(body.msg.msgtype).toBe('action_card') expect(body.msg.action_card.title).toBe('测试通知') expect(body.msg.action_card.markdown).toBe('这是一条测试通知') expect(body.msg.action_card.single_title).toBe('立即填写') expect(body.msg.action_card.single_url).toContain('dingtalk://dingtalkclient/action/openapp') expect(body.msg.action_card.single_url).toContain('corpid=ding-test-corp') expect(body.msg.action_card.single_url).toContain('app_id=0_4279191869') expect(body.msg.action_card.single_url).toContain(encodeURIComponent('https://sc.jiansun.vip/dingtalk-entry?redirect=%2Fperformance-survey')) }) test('工作通知发送结果会区分已读和未读人员', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '4279191869' ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: 'notice-token', expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, send_result: { read_user_id_list: ['ding-read'], unread_user_id_list: ['ding-unread'], failed_user_id_list: ['ding-failed'], }, }), }) const { getDingtalkWorkNoticeSendResult } = await import('@/lib/dingtalk-client') const result = await getDingtalkWorkNoticeSendResult('task-1') expect(result.readUserIds).toEqual(['ding-read']) expect(result.unreadUserIds).toEqual(['ding-unread']) expect(result.failedUserIds).toEqual(['ding-failed']) const [, options] = (global.fetch as jest.Mock).mock.calls[1] expect(JSON.parse(options.body)).toEqual({ agent_id: 4279191869, task_id: 'task-1', }) }) test('机器人单聊使用 RobotCode 和 userIds 发送 ActionCard', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_ROBOT_CODE = 'ding-robot-code' ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: 'robot-token', expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ processQueryKey: 'query-1', invalidStaffIdList: [], flowControlledStaffIdList: [], }), }) const { sendDingtalkRobotOtoMessage } = await import('@/lib/dingtalk-client') const result = await sendDingtalkRobotOtoMessage({ userIds: ['ding-user-1'], title: '测试通知', text: '这是一条测试通知', actionUrl: 'https://sc.jiansun.vip/performance-survey', actionTitle: '立即填写', }) expect(result.processQueryKey).toBe('query-1') expect(global.fetch).toHaveBeenNthCalledWith( 2, 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'x-acs-dingtalk-access-token': 'robot-token', }), }), ) const [, options] = (global.fetch as jest.Mock).mock.calls[1] const body = JSON.parse(options.body) expect(body).toEqual(expect.objectContaining({ robotCode: 'ding-robot-code', userIds: ['ding-user-1'], msgKey: 'sampleActionCard', })) expect(JSON.parse(body.msgParam)).toEqual({ title: '测试通知', text: '这是一条测试通知', singleTitle: '立即填写', singleURL: 'https://sc.jiansun.vip/performance-survey', }) }) test('机器人单聊已读查询会区分已读和未读人员', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_ROBOT_CODE = 'ding-robot-code' ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: 'robot-token', expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ sendStatus: 'success', messageReadInfoList: [ { userId: 'ding-read', readStatus: 'read' }, { userId: 'ding-unread', readStatus: 'unread' }, ], }), }) const { getDingtalkRobotOtoReadResult } = await import('@/lib/dingtalk-client') const result = await getDingtalkRobotOtoReadResult('query-1') expect(result.readUserIds).toEqual(['ding-read']) expect(result.unreadUserIds).toEqual(['ding-unread']) const [url, options] = (global.fetch as jest.Mock).mock.calls[1] expect(url).toContain('/v1.0/robot/oToMessages/readStatus?') expect(url).toContain('robotCode=ding-robot-code') expect(url).toContain('processQueryKey=query-1') expect(options).toEqual(expect.objectContaining({ method: 'GET' })) }) test('机器人单聊唯一收件人被过滤时不会误报成功', async () => { process.env.DINGTALK_CLIENT_ID = 'login-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_ROBOT_CODE = 'ding-robot-code' ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ errcode: 0, access_token: 'robot-token', expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ processQueryKey: 'query-1', invalidStaffIdList: [], filteredStaffIdList: ['ding-user-1'], flowControlledStaffIdList: [], }), }) const { sendDingtalkRobotOtoMessage } = await import('@/lib/dingtalk-client') await expect(sendDingtalkRobotOtoMessage({ userIds: ['ding-user-1'], title: '测试通知', text: '这是一条测试通知', })).rejects.toThrow('收件人被钉钉过滤') }) test('机器人单聊不会把两套应用的半套凭证拼成已就绪', async () => { process.env.DINGTALK_WORK_NOTICE_CLIENT_ID = 'notice-client-id' process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' process.env.DINGTALK_ROBOT_CODE = 'ding-robot-code' const { getDingtalkRobotOtoConfigStatus } = await import('@/lib/dingtalk-client') expect(getDingtalkRobotOtoConfigStatus()).toEqual(expect.objectContaining({ ready: false, appConfigured: false, appSource: 'workNotice', })) }) test('机器人单聊灰度白名单只允许指定人员切换通道', async () => { process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL = 'robot_oto' process.env.DINGTALK_ROBOT_OTO_TEST_USER_IDS = 'ding-user-1, ding-user-2,ding-user-1' const { getDingtalkPerformanceSurveyNotificationChannelForUser, getDingtalkRobotOtoTestUserIds, } = await import('@/lib/dingtalk-client') expect(getDingtalkRobotOtoTestUserIds()).toEqual(['ding-user-1', 'ding-user-2']) expect(getDingtalkPerformanceSurveyNotificationChannelForUser('ding-user-1')).toBe('robot_oto') expect(getDingtalkPerformanceSurveyNotificationChannelForUser('ding-user-3')).toBe('work_notice') }) test('机器人单聊灰度白名单留空时按全量通道处理', async () => { process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL = 'robot_oto' const { getDingtalkPerformanceSurveyNotificationChannelForUser } = await import('@/lib/dingtalk-client') expect(getDingtalkPerformanceSurveyNotificationChannelForUser('ding-user-1')).toBe('robot_oto') }) test('统一通知中心白名单留空时对所有钉钉账户生效', async () => { process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL = 'notification_center' const { getDingtalkPerformanceSurveyNotificationChannelForUser } = await import('@/lib/dingtalk-client') expect(getDingtalkPerformanceSurveyNotificationChannelForUser('ding-user-1')).toBe('notification_center') expect(getDingtalkPerformanceSurveyNotificationChannelForUser('ding-user-2')).toBe('notification_center') }) })