322 lines
14 KiB
TypeScript
322 lines
14 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),
|
|
},
|
|
})
|
|
Object.defineProperty(document, 'execCommand', {
|
|
configurable: true,
|
|
value: jest.fn().mockImplementation((command: string) => {
|
|
if (command !== 'copy') return false
|
|
clipboardText = document.querySelector('textarea[aria-hidden="true"]')?.textContent
|
|
|| (document.querySelector('textarea[aria-hidden="true"]') as HTMLTextAreaElement | null)?.value
|
|
|| ''
|
|
return true
|
|
}),
|
|
})
|
|
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('浏览器拒绝 Clipboard API 时回退到兼容复制', async () => {
|
|
const user = userEvent.setup()
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
writeText: jest.fn().mockRejectedValue(new Error('NotAllowedError')),
|
|
},
|
|
})
|
|
render(<NotificationCenterPanel showToast={showToast} />)
|
|
await screen.findByText('素材平台')
|
|
|
|
await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' }))
|
|
await user.click(screen.getByRole('button', { name: '查看当前密钥' }))
|
|
await screen.findByText('current-project-secret')
|
|
await user.click(screen.getByRole('button', { name: '复制 素材平台 的当前项目密钥' }))
|
|
|
|
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
|
expect(clipboardText).toBe('current-project-secret')
|
|
expect(showToast).toHaveBeenCalledWith('项目密钥已复制', 'success')
|
|
})
|
|
|
|
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('项目配置没有被修改')
|
|
})
|
|
})
|