gongxiangp2/lib/__tests__/notification-center-admin.test.ts
2026-08-29 21:39:17 +08:00

317 lines
11 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 */
jest.mock('@/lib/prisma', () => ({
prisma: {
performanceSurveyEmployeeProfile: {
findMany: jest.fn(),
},
},
}))
import {
createNotificationCenterProject,
deleteNotificationCenterProject,
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
NotificationCenterAdminError,
revealNotificationCenterProjectSecret,
resolveNotificationCenterRecipientAlias,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
} from '@/lib/notification-center-admin'
import { prisma } from '@/lib/prisma'
describe('notification-center-admin client', () => {
const actor = {
userId: 'super-1',
username: 'wuxie',
realName: '无邪',
email: 'wuxie@example.com',
}
beforeEach(() => {
jest.restoreAllMocks()
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset()
process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL = 'https://notify-admin.example.com'
process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret'
delete process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS
global.fetch = jest.fn()
})
test('精确花名解析只返回唯一有效的钉钉 userId', async () => {
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([
{ dingtalkUserId: ' ding-user-1 ' },
{ dingtalkUserId: 'ding-user-1' },
{ dingtalkUserId: null },
])
await expect(resolveNotificationCenterRecipientAlias(' 测试员工 ')).resolves.toEqual({
recipientUserId: 'ding-user-1',
})
expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith({
where: { flowerName: '测试员工' },
select: { dingtalkUserId: true },
take: 5,
})
})
test('花名对应多个不同钉钉 userId 时不返回模糊结果', async () => {
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([
{ dingtalkUserId: 'ding-user-1' },
{ dingtalkUserId: 'ding-user-2' },
])
await expect(resolveNotificationCenterRecipientAlias('重名员工')).resolves.toBeNull()
})
test('列表查询会透传操作人身份并剥离 project/admin secret', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
items: [
{
code: 'material_library',
name: '素材库',
projectSecret: 'should-not-return',
env: 'SECRET=1',
adminSecret: 'server-admin-secret',
},
],
adminKey: 'server-admin-secret',
}),
})
const result = await listNotificationCenterProjects(actor)
expect(result.status).toBe(200)
expect(result.data).toEqual({
items: [
{
code: 'material_library',
name: '素材库',
},
],
})
const [, init] = (global.fetch as jest.Mock).mock.calls[0]
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer server-admin-secret')
expect(headers['x-yyd-admin-actor']).toBe('super-1')
expect(headers['x-yyd-admin-display-name']).toBe('无邪')
})
test('创建项目仅保留一次性 project secret/env,不回传 admin secret', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 201,
text: async () => JSON.stringify({
code: 'material_library',
projectSecret: 'project-secret-1',
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
adminSecret: 'server-admin-secret',
adminKey: 'server-admin-secret',
}),
})
const result = await createNotificationCenterProject(actor, { code: 'material_library' })
expect(result.status).toBe(201)
expect(result.data).toEqual({
code: 'material_library',
projectSecret: 'project-secret-1',
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
})
})
test('轮换密钥同样只保留一次性 project secret/env', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
projectSecret: 'next-secret',
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret',
adminToken: 'server-admin-secret',
}),
})
const result = await rotateNotificationCenterProjectSecret(actor, 'material_library')
expect(result.data).toEqual({
projectSecret: 'next-secret',
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret',
})
})
test('查看当前密钥命中专属接口,保留 project secret 且剥离 admin secret', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
projectCode: 'script_monitor',
secret: 'current-project-secret',
env: {
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET: 'server-admin-secret',
},
project: { recipients: [{ userId: 'ding-private-user' }] },
adminSecret: 'server-admin-secret',
}),
})
const result = await revealNotificationCenterProjectSecret(actor, 'script_monitor')
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(
'https://notify-admin.example.com/projects/script_monitor/reveal-secret',
)
expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({
method: 'POST',
cache: 'no-store',
})
expect(result.data).toEqual({
projectCode: 'script_monitor',
secret: 'current-project-secret',
env: {
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
},
})
expect(JSON.stringify(result.data)).not.toContain('server-admin-secret')
expect(JSON.stringify(result.data)).not.toContain('ding-private-user')
})
test('PATCH/DELETE 项目更新与项目级 recipients/test 会命中嵌套路径', async () => {
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify({ code: 'material_library', name: '素材库-新版' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify([{ userId: 'ding-1' }]),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify({ id: 'test-1' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify({ deleted: true }),
})
await updateNotificationCenterProject(actor, 'material_library', { name: '素材库-新版' })
await listNotificationCenterProjectRecipients(
actor,
'material_library',
new URLSearchParams('page=2'),
)
await sendNotificationCenterProjectTest(actor, 'material_library', {
title: '连通性测试',
projectCode: 'material_library',
})
await deleteNotificationCenterProject(actor, 'material_library')
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(
'https://notify-admin.example.com/projects/material_library',
)
expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({
method: 'PATCH',
})
expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe(
'https://notify-admin.example.com/projects/material_library/recipients?page=2',
)
expect((global.fetch as jest.Mock).mock.calls[2][0]).toBe(
'https://notify-admin.example.com/projects/material_library/test',
)
expect((global.fetch as jest.Mock).mock.calls[2][1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ title: '连通性测试' }),
})
expect((global.fetch as jest.Mock).mock.calls[3][0]).toBe(
'https://notify-admin.example.com/projects/material_library',
)
expect((global.fetch as jest.Mock).mock.calls[3][1]).toMatchObject({
method: 'DELETE',
})
})
test('下游 401 会转成中文配置错误且不泄漏管理员密钥', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 401,
text: async () => JSON.stringify({
error: 'invalid admin key',
adminKey: 'server-admin-secret',
}),
})
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 502,
code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED',
})
await listNotificationCenterProjects(actor).catch((error: NotificationCenterAdminError) => {
expect(error.message).toContain('通知中心管理员鉴权失败')
expect(error.message).not.toContain('server-admin-secret')
expect(error.details || '').not.toContain('server-admin-secret')
})
})
test('409 与 422 会保留业务状态并输出中文错误', async () => {
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => JSON.stringify({ error: 'project already exists' }),
})
.mockResolvedValueOnce({
ok: false,
status: 422,
text: async () => JSON.stringify({ message: 'code is required' }),
})
await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({
statusCode: 409,
code: 'NOTIFICATION_CENTER_ADMIN_CONFLICT',
})
await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({
statusCode: 422,
code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION',
})
})
test('下游 500 会转成中文服务异常', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 500,
text: async () => JSON.stringify({ error: 'internal error' }),
})
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 502,
code: 'NOTIFICATION_CENTER_ADMIN_UPSTREAM',
})
})
test('离线与超时会映射为中文错误', async () => {
;(global.fetch as jest.Mock)
.mockRejectedValueOnce(new TypeError('fetch failed'))
.mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' }))
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 503,
code: 'NOTIFICATION_CENTER_ADMIN_OFFLINE',
})
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 504,
code: 'NOTIFICATION_CENTER_ADMIN_TIMEOUT',
})
})
})