gongxiangp2/components/admin/NotificationCenterPanel.test.tsx
2026-08-29 21:39:17 +08:00

291 lines
13 KiB
TypeScript

import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import NotificationCenterPanel from './NotificationCenterPanel'
jest.mock('@gsap/react', () => ({
useGSAP: jest.fn(),
}))
jest.mock('gsap', () => ({
registerPlugin: jest.fn(),
matchMedia: () => ({
add: jest.fn(),
revert: jest.fn(),
}),
fromTo: jest.fn(),
}))
const project = {
name: '素材平台',
projectCode: 'material_library',
isActive: true,
allowedHosts: ['https://sc.jiansun.vip'],
recipientMode: 'alias',
updatedAt: '2026-08-28T08:00:00.000Z',
deliverySummary: { lastStatus: 'sent', lastSentAt: '2026-08-28T08:00:00.000Z', recentFailedCount: 0 },
}
const jsonResponse = (body: unknown, status = 200) =>
Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response)
describe('NotificationCenterPanel', () => {
const showToast = jest.fn()
let clipboardText = ''
let projectList = [project]
let deleteShouldFail = false
beforeEach(() => {
jest.clearAllMocks()
clipboardText = ''
projectList = [{ ...project }]
deleteShouldFail = false
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: jest.fn().mockImplementation(async (value: string) => {
clipboardText = value
}),
readText: jest.fn().mockImplementation(async () => clipboardText),
},
})
global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/api/admin/notification-center/projects') && (!init?.method || init.method === 'GET')) {
return jsonResponse({ success: true, data: { projects: projectList } })
}
if (url.endsWith('/api/admin/notification-center/projects') && init?.method === 'POST') {
return jsonResponse({
success: true,
data: {
projectCode: 'fixed_assets',
secret: 'one-time-project-secret',
env: {
YYD_NOTIFICATION_CENTER_URL: 'https://sc.jiansun.vip/notification-center',
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'fixed_assets',
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'one-time-project-secret',
},
},
}, 201)
}
if (url.includes('/rotate-secret') && init?.method === 'POST') {
return jsonResponse({
success: true,
data: {
secret: 'rotated-one-time-secret',
env: { YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'rotated-one-time-secret' },
},
})
}
if (url.includes('/reveal-secret') && init?.method === 'POST') {
return jsonResponse({
success: true,
data: {
projectCode: 'material_library',
secret: 'current-project-secret',
env: {
YYD_NOTIFICATION_CENTER_URL: 'https://sc.jiansun.vip/notification-center',
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'material_library',
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
},
},
})
}
if (url.endsWith('/api/admin/notification-center/projects/material_library') && init?.method === 'DELETE') {
if (deleteShouldFail) {
return jsonResponse({ error: { message: '通知中心服务异常,请稍后重试' } }, 502)
}
projectList = []
return jsonResponse({ success: true, data: { projectCode: 'material_library', deleted: true } })
}
if (url.endsWith('/test') && init?.method === 'POST') {
return jsonResponse({ deliveryId: 'delivery-1', status: 'pending' }, 202)
}
if (url.includes('/deliveries')) {
return jsonResponse({ deliveries: [] })
}
return jsonResponse({ error: { message: 'unexpected request' } }, 500)
}) as jest.Mock
})
test('默认展示极简接入总览并说明标题前缀与外部项目边界', async () => {
render(<NotificationCenterPanel showToast={showToast} />)
expect(await screen.findByText('素材平台')).toBeInTheDocument()
expect(screen.getAllByText(/通知中心会统一在标题前加上项目名称/)).toHaveLength(2)
expect(screen.getAllByText(/正文、按钮和跳转地址由外部项目自行决定/)).toHaveLength(2)
expect(screen.queryByRole('button', { name: /收件人花名/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '新增接入项目' })).toBeInTheDocument()
})
test('新建项目只填名称也会直接展示 URL、projectCode 和项目密钥', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: '新增接入项目' }))
await user.type(screen.getByLabelText('项目名称'), '固定资产系统')
await user.click(screen.getByRole('button', { name: '创建并生成密钥' }))
expect(await screen.findByText('one-time-project-secret')).toBeInTheDocument()
expect(screen.getByText('https://sc.jiansun.vip/notification-center')).toBeInTheDocument()
expect(screen.getByText('fixed_assets')).toBeInTheDocument()
expect(global.fetch).toHaveBeenCalledWith(
'/api/admin/notification-center/projects',
expect.objectContaining({ method: 'POST' }),
)
})
test('高级设置允许按需自定义 projectCode', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: '新增接入项目' }))
await user.type(screen.getByLabelText('项目名称'), '固定资产系统')
await user.click(screen.getByRole('button', { name: '高级设置:自定义 projectCode' }))
await user.type(screen.getByLabelText('projectCode'), 'fixed_assets')
await user.click(screen.getByRole('button', { name: '创建并生成密钥' }))
const request = (global.fetch as jest.Mock).mock.calls.find(
([url, init]) => String(url) === '/api/admin/notification-center/projects' && (init as RequestInit)?.method === 'POST',
)
expect(request).toBeTruthy()
expect((request?.[1] as RequestInit).body).toContain('"projectCode":"fixed_assets"')
})
test('密钥轮换要求二次确认且新密钥只在确认后展示', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' }))
await user.click(screen.getByRole('button', { name: '查看当前密钥' }))
expect(await screen.findByText('current-project-secret')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '轮换项目密钥' }))
expect(screen.getByText(/旧密钥的调用会立即鉴权失败/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '确认轮换,旧密钥立即失效' }))
expect(await screen.findByText('rotated-one-time-secret')).toBeInTheDocument()
expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(screen.getByRole('button', { name: '查看当前密钥' })).toBeInTheDocument()
})
test('删除项目需要单独确认,成功后刷新列表并关闭抽屉', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' }))
await user.click(screen.getByRole('button', { name: '删除项目' }))
const dialog = await screen.findByRole('alertdialog', { name: '确认删除“素材平台”项目?' })
expect(within(dialog).getByText('删除项目配置并使密钥立即失效,历史投递记录保留。')).toBeInTheDocument()
expect(
within(dialog).getByText('如仍需继续发送通知,请在删除后重新创建项目并同步更新调用方环境变量。'),
).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: '确认删除项目' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/admin/notification-center/projects/material_library',
expect.objectContaining({ method: 'DELETE' }),
)
})
await waitFor(() => {
expect(screen.queryByRole('heading', { name: '查看配置 / 编辑资料' })).not.toBeInTheDocument()
})
expect(await screen.findByText('还没有接入项目')).toBeInTheDocument()
expect(showToast).toHaveBeenCalledWith('接入项目已删除,项目密钥已立即失效', 'success')
})
test('删除项目失败时保留确认弹窗并提示错误 toast', async () => {
deleteShouldFail = true
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' }))
await user.click(screen.getByRole('button', { name: '删除项目' }))
const dialog = await screen.findByRole('alertdialog', { name: '确认删除“素材平台”项目?' })
await user.click(within(dialog).getByRole('button', { name: '确认删除项目' }))
expect(await within(dialog).findByRole('alert')).toHaveTextContent('通知中心服务异常,请稍后重试')
expect(showToast).toHaveBeenCalledWith('通知中心服务异常,请稍后重试', 'error')
expect(screen.getByRole('alertdialog', { name: '确认删除“素材平台”项目?' })).toBeInTheDocument()
expect(screen.queryByText('还没有接入项目')).not.toBeInTheDocument()
})
test('已接入项目可查看资料并按需查看、复制和隐藏当前密钥', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
expect(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })).toHaveTextContent('查看配置 / 编辑资料')
await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' }))
expect(screen.getByRole('heading', { name: '查看配置 / 编辑资料' })).toBeInTheDocument()
expect(screen.getByLabelText('项目名称')).toHaveValue('素材平台')
expect(screen.getByLabelText('projectCode')).toHaveValue('material_library')
expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '查看当前密钥' }))
expect(await screen.findByText('current-project-secret')).toBeInTheDocument()
expect(global.fetch).toHaveBeenCalledWith(
'/api/admin/notification-center/projects/material_library/reveal-secret',
expect.objectContaining({ method: 'POST', cache: 'no-store' }),
)
await user.click(screen.getByRole('button', { name: '复制 素材平台 的当前项目密钥' }))
expect(await navigator.clipboard.readText()).toBe('current-project-secret')
expect(showToast).toHaveBeenCalledWith('项目密钥已复制', 'success')
await user.click(screen.getByRole('button', { name: '隐藏密钥' }))
expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument()
})
test('测试通知支持直接输入准确花名并在确认后透传 recipientAlias', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: /测试通知/ }))
expect(screen.getByDisplayValue('准确花名')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('真实收件人'), { target: { value: '测试员工' } })
await user.click(screen.getByRole('button', { name: '向指定员工发送测试通知' }))
expect(screen.getByText(/按当前花名册精确解析 花名:测试员工/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '确认发送给该员工' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/admin/notification-center/projects/material_library/test',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
recipientAlias: '测试员工',
title: '统一通知中心测试',
content: '这是一条由超级管理员发起的链路测试通知。',
actionTitle: '查看详情',
}),
}),
)
})
})
test('通知中心离线时显示可恢复错误且不伪装成空数据', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('连接超时'))
render(<NotificationCenterPanel showToast={showToast} />)
expect(await screen.findByRole('alert')).toHaveTextContent('连接超时')
expect(screen.getByRole('alert')).toHaveTextContent('项目配置没有被修改')
})
})