gongxiangp2/lib/__tests__/performance-survey-notifications.test.ts
2026-07-23 09:25:50 +08:00

547 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import {
processPerformanceSurveyNotification,
recoverMissingSurveyAssignmentNotifications,
queueSurveyAssignmentNotifications,
queueSurveyTargetConfirmationNotifications,
recoverDuePerformanceSurveyNotifications,
refreshPerformanceSurveyNotificationReadStatuses,
retryPerformanceSurveyNotification,
} from '@/lib/performance-survey-notifications'
import { prisma } from '@/lib/prisma'
import {
getDingtalkRobotOtoReadResult,
getDingtalkWorkNoticeSendResult,
sendDingtalkRobotOtoMessage,
sendDingtalkWorkNotice,
} from '@/lib/dingtalk-client'
jest.mock('@/lib/prisma', () => ({
prisma: {
performanceSurvey: {
findUnique: jest.fn(),
},
performanceSurveyNotification: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
findMany: jest.fn(),
},
performanceSurveyAssignment: {
findMany: jest.fn(),
},
performanceSurveyEmployeeProfile: {
findUnique: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/dingtalk-client', () => ({
getDingtalkPerformanceSurveyNotificationChannelForUser: jest.fn((userId: string) => {
if (process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL !== 'robot_oto') {
return 'work_notice'
}
const allowlist = String(process.env.DINGTALK_ROBOT_OTO_TEST_USER_IDS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
return allowlist.length === 0 || allowlist.includes(userId) ? 'robot_oto' : 'work_notice'
}),
sendDingtalkRobotOtoMessage: jest.fn(),
getDingtalkRobotOtoReadResult: jest.fn(),
sendDingtalkWorkNotice: jest.fn(),
getDingtalkWorkNoticeSendResult: jest.fn(),
}))
jest.mock('@/lib/public-app-url', () => ({
getPublicAppOrigin: jest.fn(() => 'https://sc.jiansun.vip'),
}))
function notification(overrides: Record<string, unknown> = {}) {
return {
id: 'notification-1',
dingtalkUserId: null,
recipientUserId: null,
recipientEmployeeId: null,
title: '你有新的考核调研待填写',
content: '请填写考核调研',
actionUrl: 'https://sc.jiansun.vip/performance-survey/survey/survey-1',
eventType: 'survey.assignment.created',
status: 'pending',
attemptCount: 0,
maxAttempts: 5,
...overrides,
}
}
function buildAssignment(role: 'self' | 'visual_final' | 'manager', status: 'pending' | 'submitted', id: string) {
return {
id,
status,
targetId: 'target-1',
evaluatorEmployeeId: `employee-${role}`,
evaluatorUserId: null,
evaluatorDingtalkUserId: `ding-${role}`,
evaluatorName: role,
evaluatorSnapshot: { performanceSurveyWorkflowRole: role },
evaluatorEmployee: null,
target: { id: 'target-1', targetName: '视觉同事' },
}
}
describe('performance survey notifications', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
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
;(prisma.performanceSurveyNotification.update as jest.Mock).mockResolvedValue({})
;(prisma.performanceSurveyNotification.create as jest.Mock).mockResolvedValue({ id: 'notification-confirmation' })
;(prisma.performanceSurveyNotification.updateMany as jest.Mock).mockResolvedValue({ count: 0 })
;(prisma.performanceSurveyNotification.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyAssignment.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue(null)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
;(prisma.user.findFirst as jest.Mock).mockResolvedValue(null)
;(sendDingtalkWorkNotice as jest.Mock).mockResolvedValue({
taskId: 'task-1',
raw: { errcode: 0, task_id: 'task-1' },
})
;(getDingtalkWorkNoticeSendResult as jest.Mock).mockResolvedValue({
readUserIds: [],
unreadUserIds: [],
failedUserIds: [],
invalidUserIds: [],
forbiddenUserIds: [],
raw: { errcode: 0 },
})
;(sendDingtalkRobotOtoMessage as jest.Mock).mockResolvedValue({
processQueryKey: 'query-1',
invalidUserIds: [],
filteredUserIds: [],
flowControlledUserIds: [],
raw: { processQueryKey: 'query-1' },
})
;(getDingtalkRobotOtoReadResult as jest.Mock).mockResolvedValue({
readUserIds: [],
unreadUserIds: [],
sendStatus: 'success',
deliveryFailed: false,
raw: { sendStatus: 'success' },
})
})
afterEach(() => {
jest.useRealTimers()
})
test('发送前会从素材库用户绑定补齐钉钉 userId', async () => {
;(prisma.performanceSurveyNotification.findUnique as jest.Mock).mockResolvedValue(
notification({ recipientUserId: 'user-1' }),
)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ dingtalkUserId: 'ding-user-1' })
const result = await processPerformanceSurveyNotification('notification-1')
expect(result).toEqual({ success: true, status: 'succeeded' })
expect(sendDingtalkWorkNotice).toHaveBeenCalledWith({
userIds: ['ding-user-1'],
title: '你有新的考核调研待填写',
text: '请填写考核调研',
actionUrl: 'https://sc.jiansun.vip/performance-survey/survey/survey-1',
actionTitle: '立即填写',
})
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({ dingtalkUserId: 'ding-user-1' }),
})
})
test('人员档案缺 userId 时会通过邮箱匹配素材库用户', async () => {
;(prisma.performanceSurveyNotification.findUnique as jest.Mock).mockResolvedValue(
notification({ recipientEmployeeId: 'employee-1' }),
)
;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({
dingtalkUserId: null,
dingEmail: 'user@example.com',
})
;(prisma.user.findFirst as jest.Mock).mockResolvedValue({ dingtalkUserId: 'ding-user-2' })
await processPerformanceSurveyNotification('notification-1')
expect(sendDingtalkWorkNotice).toHaveBeenCalledWith(expect.objectContaining({
userIds: ['ding-user-2'],
}))
})
test('配置机器人单聊后会记录 processQueryKey 和 providerType', async () => {
process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL = 'robot_oto'
;(prisma.performanceSurveyNotification.findUnique as jest.Mock).mockResolvedValue(
notification({ dingtalkUserId: 'ding-user-1' }),
)
const result = await processPerformanceSurveyNotification('notification-1')
expect(result).toEqual({ success: true, status: 'succeeded' })
expect(sendDingtalkRobotOtoMessage).toHaveBeenCalledWith(expect.objectContaining({
userIds: ['ding-user-1'],
actionTitle: '立即填写',
}))
expect(sendDingtalkWorkNotice).not.toHaveBeenCalled()
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({
status: 'succeeded',
providerType: 'robot_oto',
providerMessageId: 'query-1',
}),
})
})
test('机器人单聊灰度期间非白名单人员继续使用工作通知', async () => {
process.env.DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL = 'robot_oto'
process.env.DINGTALK_ROBOT_OTO_TEST_USER_IDS = 'ding-user-2'
;(prisma.performanceSurveyNotification.findUnique as jest.Mock).mockResolvedValue(
notification({ dingtalkUserId: 'ding-user-1' }),
)
;(sendDingtalkWorkNotice as jest.Mock).mockResolvedValue({ taskId: 'task-1' })
const result = await processPerformanceSurveyNotification('notification-1')
expect(result).toEqual({ success: true, status: 'succeeded' })
expect(sendDingtalkRobotOtoMessage).not.toHaveBeenCalled()
expect(sendDingtalkWorkNotice).toHaveBeenCalledWith(expect.objectContaining({
userIds: ['ding-user-1'],
}))
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({
providerType: 'work_notice',
providerMessageId: 'task-1',
}),
})
})
test('缺少钉钉 userId 时记录为 skipped,不调用钉钉接口', async () => {
;(prisma.performanceSurveyNotification.findUnique as jest.Mock).mockResolvedValue(notification())
const result = await processPerformanceSurveyNotification('notification-1')
expect(result).toEqual({ success: false, status: 'skipped' })
expect(sendDingtalkWorkNotice).not.toHaveBeenCalled()
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({
status: 'skipped',
lastError: '收件人缺少钉钉 userId',
}),
})
})
test('代他人发起调研时只向其他被考核人发送确认通知', async () => {
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue({
id: 'survey-1',
title: '七月课程分享',
creatorUserId: 'user-1',
creatorName: '发起人',
creatorEmployeeId: 'employee-self',
dueAt: new Date('2026-07-01T00:00:00.000Z'),
targets: [
{
id: 'target-self',
targetType: 'employee',
employeeProfileId: 'employee-self',
targetName: '发起人',
employeeProfile: {
dingtalkUserId: 'ding-self',
dingEmail: 'self@example.com',
},
},
{
id: 'target-other',
targetType: 'employee',
employeeProfileId: 'employee-other',
targetName: '被代发起人',
employeeProfile: {
dingtalkUserId: 'ding-other',
dingEmail: 'other@example.com',
},
},
],
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
dingtalkUserId: 'ding-self',
email: 'self@example.com',
})
;(prisma.performanceSurveyNotification.findUnique as jest.Mock).mockResolvedValue(null)
const ids = await queueSurveyTargetConfirmationNotifications('survey-1')
expect(ids).toEqual(['notification-confirmation'])
expect(prisma.performanceSurveyNotification.create).toHaveBeenCalledTimes(1)
expect(prisma.performanceSurveyNotification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
surveyId: 'survey-1',
targetId: 'target-other',
recipientEmployeeId: 'employee-other',
dingtalkUserId: 'ding-other',
recipientName: '被代发起人',
eventType: 'survey.target.confirmation.required',
businessKey: 'target-confirmation:survey-1:target-other',
title: '考核调研',
actionUrl: expect.stringContaining('/dingtalk-entry?redirect='),
status: 'pending',
}),
select: { id: true },
})
})
test('视觉评分确认通知会直接进入对应作品上传页', async () => {
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue({
id: 'survey-visual',
title: '七月视觉评分',
templateCode: 'visual_brand_tone',
templateSnapshot: { requiredWorkItemCount: 3 },
creatorUserId: 'user-1',
creatorName: '发起人',
creatorEmployeeId: 'employee-self',
dueAt: new Date('2026-07-01T00:00:00.000Z'),
targets: [{
id: 'target-other',
targetType: 'employee',
employeeProfileId: 'employee-other',
targetName: '视觉同事',
employeeProfile: {
dingtalkUserId: 'ding-other',
dingEmail: 'other@example.com',
},
}],
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
dingtalkUserId: 'ding-self',
email: 'self@example.com',
})
;(prisma.performanceSurveyNotification.findUnique as jest.Mock).mockResolvedValue(null)
await queueSurveyTargetConfirmationNotifications('survey-visual')
expect(prisma.performanceSurveyNotification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
title: '考核调研',
content: expect.stringContaining('**作品要求:** 3 个案例作品'),
actionUrl: expect.stringContaining(
'%2Fperformance-survey%2Fsurvey%2Fsurvey-visual%3FtargetId%3Dtarget-other',
),
}),
select: { id: true },
})
const createdContent = (prisma.performanceSurveyNotification.create as jest.Mock).mock.calls[0][0].data.content
expect(createdContent).toContain('### 🖼️ 待提交')
expect(createdContent).toContain('**七月视觉评分**')
})
test('视觉评分创建后只为自评任务排队通知', async () => {
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue({
id: 'survey-visual',
title: '七月视觉评分',
templateCode: 'visual_brand_tone',
dueAt: null,
assignments: [
buildAssignment('self', 'pending', 'assignment-self'),
buildAssignment('visual_final', 'pending', 'assignment-final'),
buildAssignment('manager', 'pending', 'assignment-manager'),
],
})
await queueSurveyAssignmentNotifications('survey-visual')
expect(prisma.performanceSurveyNotification.create).toHaveBeenCalledTimes(1)
expect(prisma.performanceSurveyNotification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
assignmentId: 'assignment-self',
title: '考核调研',
content: [
'### ◆ 待填写',
'',
'**七月视觉评分**',
'',
'**评价对象:** 视觉同事 ',
'',
'> 请先完成自评;提交后系统才会通知主管和终审人评分',
].join('\n'),
}),
select: { id: true },
})
})
test('视觉评分自评完成后才为元泽和主管排队通知', async () => {
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue({
id: 'survey-visual',
title: '七月视觉评分',
templateCode: 'visual_brand_tone',
dueAt: null,
assignments: [
buildAssignment('self', 'submitted', 'assignment-self'),
buildAssignment('visual_final', 'pending', 'assignment-final'),
buildAssignment('manager', 'pending', 'assignment-manager'),
],
})
await queueSurveyAssignmentNotifications('survey-visual')
expect(prisma.performanceSurveyNotification.create).toHaveBeenCalledTimes(2)
expect((prisma.performanceSurveyNotification.create as jest.Mock).mock.calls.map((call) => call[0].data.assignmentId))
.toEqual(['assignment-final', 'assignment-manager'])
})
test('维护任务会补建自评完成后缺失的评分通知', async () => {
;(prisma.performanceSurveyAssignment.findMany as jest.Mock).mockResolvedValue([
{ surveyId: 'survey-visual' },
])
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue({
id: 'survey-visual',
title: '七月视觉评分',
templateCode: 'visual_brand_tone',
dueAt: null,
assignments: [
buildAssignment('self', 'submitted', 'assignment-self'),
buildAssignment('visual_final', 'pending', 'assignment-final'),
buildAssignment('manager', 'pending', 'assignment-manager'),
],
})
const recovered = await recoverMissingSurveyAssignmentNotifications()
expect(recovered).toBe(2)
expect(prisma.performanceSurveyAssignment.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
status: 'pending',
notifications: { none: { eventType: 'survey.assignment.created' } },
}),
distinct: ['surveyId'],
}))
expect((prisma.performanceSurveyNotification.create as jest.Mock).mock.calls.map((call) => call[0].data.assignmentId))
.toEqual(['assignment-final', 'assignment-manager'])
})
test('手工补发会重置重试次数和历史发送结果', async () => {
await retryPerformanceSurveyNotification('notification-1')
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({
status: 'pending',
attemptCount: 0,
maxAttempts: 5,
lastStatusCode: null,
lastResponseBody: null,
lastError: null,
providerMessageId: null,
sentAt: null,
}),
})
})
test('维护任务会把长时间 processing 的通知重新入队', async () => {
;(prisma.performanceSurveyNotification.findMany as jest.Mock).mockResolvedValue([{ id: 'notification-1' }])
const recovered = await recoverDuePerformanceSurveyNotifications()
expect(recovered).toBe(1)
expect(prisma.performanceSurveyNotification.updateMany).toHaveBeenCalledWith({
where: {
status: 'processing',
updatedAt: { lte: expect.any(Date) },
},
data: expect.objectContaining({
status: 'retrying',
lastError: '上次投递处理中断,已重新入队',
}),
})
})
test('通知发送结果会把收件人更新为已查看', async () => {
;(prisma.performanceSurveyNotification.findMany as jest.Mock).mockResolvedValue([{
id: 'notification-1',
providerMessageId: 'task-1',
dingtalkUserId: 'ding-user-1',
}])
;(getDingtalkWorkNoticeSendResult as jest.Mock).mockResolvedValue({
readUserIds: ['ding-user-1'],
unreadUserIds: [],
failedUserIds: [],
invalidUserIds: [],
forbiddenUserIds: [],
raw: { errcode: 0 },
})
const count = await refreshPerformanceSurveyNotificationReadStatuses()
expect(count).toBe(1)
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({
readStatus: 'read',
readAt: expect.any(Date),
readStatusCheckedAt: expect.any(Date),
}),
})
})
test('机器人单聊通知使用 processQueryKey 查询已读状态', async () => {
;(prisma.performanceSurveyNotification.findMany as jest.Mock).mockResolvedValue([{
id: 'notification-1',
providerType: 'robot_oto',
providerMessageId: 'query-1',
dingtalkUserId: 'ding-user-1',
}])
;(getDingtalkRobotOtoReadResult as jest.Mock).mockResolvedValue({
readUserIds: ['ding-user-1'],
unreadUserIds: [],
sendStatus: 'success',
deliveryFailed: false,
raw: { sendStatus: 'success' },
})
const count = await refreshPerformanceSurveyNotificationReadStatuses()
expect(count).toBe(1)
expect(getDingtalkRobotOtoReadResult).toHaveBeenCalledWith('query-1')
expect(getDingtalkWorkNoticeSendResult).not.toHaveBeenCalled()
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({ readStatus: 'read' }),
})
})
test('机器人单聊发送状态明确失败时会记录为投递失败', async () => {
;(prisma.performanceSurveyNotification.findMany as jest.Mock).mockResolvedValue([{
id: 'notification-1',
providerType: 'robot_oto',
providerMessageId: 'query-1',
dingtalkUserId: 'ding-user-1',
}])
;(getDingtalkRobotOtoReadResult as jest.Mock).mockResolvedValue({
readUserIds: [],
unreadUserIds: [],
sendStatus: 'failed',
deliveryFailed: true,
raw: { sendStatus: 'failed' },
})
await refreshPerformanceSurveyNotificationReadStatuses()
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
where: { id: 'notification-1' },
data: expect.objectContaining({ readStatus: 'delivery_failed' }),
})
})
})