306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/** @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),
|
||
}),
|
||
})
|
||
})
|
||
})
|