/** @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 = {}) { 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' }), }) }) })