import test from 'node:test' import assert from 'node:assert/strict' import { DingTalkApiError, getDingTalkConfigStatus, getDingTalkRobotSendResult, sendDingTalkRobotNotification } from '../dingtalkClient' const envKeys = [ 'DINGTALK_APP_KEY', 'DINGTALK_APP_SECRET', 'DINGTALK_AGENT_ID', 'DINGTALK_ROBOT_CODE', 'DINGTALK_CORP_ID', 'DINGTALK_PUBLIC_BASE_URL', 'DINGTALK_SEND_ENABLED', 'DINGTALK_NOTIFICATION_CHANNEL' ] as const const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) const originalFetch = global.fetch const configureDingTalk = () => { process.env.DINGTALK_APP_KEY = 'ding-app-key' process.env.DINGTALK_APP_SECRET = 'ding-app-secret' process.env.DINGTALK_AGENT_ID = '10001' process.env.DINGTALK_ROBOT_CODE = 'ding-robot-code' process.env.DINGTALK_CORP_ID = 'ding-corp' process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com' process.env.DINGTALK_SEND_ENABLED = 'true' process.env.DINGTALK_NOTIFICATION_CHANNEL = 'robot_oto' } test.after(() => { global.fetch = originalFetch for (const key of envKeys) { if (originalEnv[key] === undefined) delete process.env[key] else process.env[key] = originalEnv[key] } }) test('机器人单聊配置状态要求 RobotCode 且不返回任何密钥值', () => { configureDingTalk() const status = getDingTalkConfigStatus() assert.deepEqual(status, { configured: true, channel: 'robot_oto', send_enabled: true, missing: [] }) assert.equal(JSON.stringify(status).includes('ding-app-secret'), false) assert.equal(JSON.stringify(status).includes('ding-robot-code'), false) }) test('机器人单聊使用 batchSend 并返回 processQueryKey', async () => { configureDingTalk() const calls: Array<{ url: string; init?: RequestInit }> = [] global.fetch = (async (url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init }) if (String(url).includes('/gettoken?')) { return new Response(JSON.stringify({ errcode: 0, access_token: 'access-token', expires_in: 7200 }), { status: 200, headers: { 'Content-Type': 'application/json' } }) } return new Response(JSON.stringify({ processQueryKey: 'query-key-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }) as typeof fetch const taskId = await sendDingTalkRobotNotification({ userId: 'ding-user-1', title: '报废审批提醒', content: '有一条报废申请等待审批', actionUrl: '/workflows/disposals/53' }) assert.equal(taskId, 'query-key-1') const sendCall = calls.find((call) => call.url.includes('/v1.0/robot/oToMessages/batchSend')) assert.ok(sendCall) assert.equal((sendCall.init?.headers as Record)['x-acs-dingtalk-access-token'], 'access-token') const body = JSON.parse(String(sendCall.init?.body)) assert.equal(body.robotCode, 'ding-robot-code') assert.deepEqual(body.userIds, ['ding-user-1']) assert.equal(body.msgKey, 'sampleActionCard') const msgParam = JSON.parse(body.msgParam) assert.equal(msgParam.singleTitle, '查看详情') assert.equal( msgParam.singleURL, 'https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2Fdisposals%2F53' ) }) test('机器人单聊通过 processQueryKey 查询已读状态', async () => { configureDingTalk() global.fetch = (async (url: string | URL | Request) => { assert.ok(String(url).includes('/v1.0/robot/oToMessages/readStatus')) return new Response(JSON.stringify({ sendStatus: 'success', messageReadInfoList: [ { userId: 'ding-read', readStatus: 'read' }, { userId: 'ding-unread', readStatus: 'unread' } ] }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }) as typeof fetch const result = await getDingTalkRobotSendResult('query-key-1') assert.deepEqual(result, { read_user_id_list: ['ding-read'], unread_user_id_list: ['ding-unread'], delivery_failed: false }) }) test('无效收件人会作为确定性错误停止重试', async () => { configureDingTalk() global.fetch = (async () => new Response(JSON.stringify({ invalidStaffIdList: ['ding-invalid'] }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch await assert.rejects( sendDingTalkRobotNotification({ userId: 'ding-invalid', title: '通知', content: '内容', actionUrl: '/dashboard' }), (error: unknown) => error instanceof DingTalkApiError && error.code === 'INVALID_USER' && error.deterministic ) })