GuDingZiChan/backend/src/services/__tests__/dingtalkClient.robot.test.ts
2026-07-15 17:44:08 +08:00

141 lines
4.5 KiB
TypeScript

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<string, string>)['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
)
})