gongxiangp2/lib/__tests__/performance-survey-notifications.test.ts
2026-07-10 17:15:54 +08:00

306 lines
10 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,
queueSurveyTargetConfirmationNotifications,
recoverDuePerformanceSurveyNotifications,
refreshPerformanceSurveyNotificationReadStatuses,
retryPerformanceSurveyNotification,
} from '@/lib/performance-survey-notifications'
import { prisma } from '@/lib/prisma'
import { getDingtalkWorkNoticeSendResult, 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(),
},
performanceSurveyEmployeeProfile: {
findUnique: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/dingtalk-client', () => ({
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',
status: 'pending',
attemptCount: 0,
maxAttempts: 5,
...overrides,
}
}
describe('performance survey notifications', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
;(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.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 },
})
})
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',
})
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('缺少钉钉 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 },
})
})
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),
}),
})
})
})