490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
import { render, screen, waitFor } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
|
||
import SharePage from './page'
|
||
|
||
const mockPush = jest.fn()
|
||
const mockReplace = jest.fn()
|
||
const mockBack = jest.fn()
|
||
const showToastMock = jest.fn()
|
||
const chunkUploadMock = jest.fn()
|
||
const originalFetch = global.fetch
|
||
|
||
const createJsonResponse = <T,>(body: T, status = 200) => ({
|
||
ok: status >= 200 && status < 300,
|
||
status,
|
||
json: async () => body,
|
||
})
|
||
|
||
const createVideoWaitResponse = () => createJsonResponse({
|
||
error: '视频类素材上传后满 15 天才能查看详情',
|
||
code: 'VIDEO_WAIT_PERIOD',
|
||
details: '视频类素材上传后满 15 天才能预览和下载,当前还需约 12 天',
|
||
availableAt: '2026-09-01T00:00:00.000Z',
|
||
remainingDays: 12,
|
||
}, 403)
|
||
|
||
const createTaskDetailMaterialResponse = () => createJsonResponse({
|
||
id: 'material-1',
|
||
userId: 'user-1',
|
||
previewUrl: '/preview.jpg',
|
||
productModel: 'GBSFY-SJZ0024',
|
||
categoryName: '顾家',
|
||
sceneName: '客厅',
|
||
materialType: '任务-详情',
|
||
authorName: '小李',
|
||
zipFileName: 'bundle.zip',
|
||
zipFileSize: '1024',
|
||
downloadCount: 0,
|
||
likeCount: 0,
|
||
displayImages: [],
|
||
createdAt: '2026-08-28T09:00:00.000Z',
|
||
capabilities: {
|
||
canFavorite: true,
|
||
canEdit: true,
|
||
canDelete: false,
|
||
},
|
||
taskNo: 'TASK-001',
|
||
taskStatus: 'ready',
|
||
taskFetchedAt: null,
|
||
isFullSet: true,
|
||
canDownloadSource: false,
|
||
canManageTaskDetailAssets: true,
|
||
assets: [
|
||
{ id: 'main-1', assetType: 'main-preview', fileUrl: '/main-1.jpg', fileName: 'main-1.jpg', fileSize: 100, sortOrder: 0 },
|
||
{ id: 'main-2', assetType: 'main-preview', fileUrl: '/main-2.jpg', fileName: 'main-2.jpg', fileSize: 100, sortOrder: 1 },
|
||
{ id: 'main-3', assetType: 'main-preview', fileUrl: '/main-3.jpg', fileName: 'main-3.jpg', fileSize: 100, sortOrder: 2 },
|
||
{ id: 'sku-1', assetType: 'sku-preview', fileUrl: '/sku-1.jpg', fileName: 'sku-1.jpg', fileSize: 100, sortOrder: 0 },
|
||
{ id: 'detail-1', assetType: 'detail-preview', fileUrl: '/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 100, sortOrder: 0 },
|
||
{ id: 'detail-2', assetType: 'detail-preview', fileUrl: '/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 100, sortOrder: 1 },
|
||
],
|
||
})
|
||
|
||
jest.mock('@gsap/react', () => ({
|
||
useGSAP: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('gsap', () => ({
|
||
registerPlugin: jest.fn(),
|
||
matchMedia: () => ({
|
||
add: jest.fn(),
|
||
revert: jest.fn(),
|
||
}),
|
||
fromTo: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('next-auth/react', () => ({
|
||
useSession: () => ({ data: null, status: 'unauthenticated' }),
|
||
}))
|
||
|
||
jest.mock('next/navigation', () => ({
|
||
useParams: () => ({ id: 'video-material-1' }),
|
||
useRouter: () => ({
|
||
push: mockPush,
|
||
replace: mockReplace,
|
||
back: mockBack,
|
||
}),
|
||
}))
|
||
|
||
jest.mock('next/image', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/Header', () => ({
|
||
__esModule: true,
|
||
default: () => <header data-testid="header" />,
|
||
}))
|
||
|
||
jest.mock('@/components/VideoPlayerModal', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/SkuList', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/SkuEditModal', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/AudioVisualizer', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/DocumentAssetViewer', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/detail/MaterialDetailHeader', () => ({
|
||
__esModule: true,
|
||
default: ({ title }: { title: string }) => <div data-testid="material-detail-header">{title}</div>,
|
||
}))
|
||
|
||
jest.mock('@/components/detail/TaskPageDetail', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/detail/ThreeDOriginalDetail', () => ({
|
||
__esModule: true,
|
||
default: () => null,
|
||
}))
|
||
|
||
jest.mock('@/components/detail/ProductPackageDetail', () => ({
|
||
__esModule: true,
|
||
default: (props: any) => (
|
||
<div data-testid="mock-product-package-detail">
|
||
<button type="button" onClick={() => props.onRequestReplace?.({ id: 'main-1', fileName: 'main-1.jpg' })}>
|
||
触发替换
|
||
</button>
|
||
<button type="button" onClick={() => props.onRequestRename?.({ id: 'main-2', fileName: 'main-2.jpg' })}>
|
||
触发重命名
|
||
</button>
|
||
<button type="button" onClick={() => props.onRequestDelete?.({ id: 'main-3', fileName: 'main-3.jpg' })}>
|
||
触发删除
|
||
</button>
|
||
<button type="button" onClick={() => props.onRequestAppend?.('main')}>
|
||
触发追加主图
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => props.onRequestInsert?.({ group: 'detail', afterAssetId: 'detail-1', beforeAssetId: 'detail-2' })}
|
||
>
|
||
触发插入详情
|
||
</button>
|
||
<div data-testid="pending-asset-ids">{(props.pendingAssetIds || []).join(',')}</div>
|
||
</div>
|
||
),
|
||
}))
|
||
|
||
jest.mock('@/hooks/useToast', () => ({
|
||
useToast: () => ({ toast: null, showToast: showToastMock }),
|
||
}))
|
||
|
||
jest.mock('@/components/ConfirmActionDialog', () => ({
|
||
useConfirmActionDialog: () => ({
|
||
confirmAction: jest.fn(),
|
||
confirmActionDialog: null,
|
||
}),
|
||
}))
|
||
|
||
jest.mock('@/lib/chunk-upload', () => ({
|
||
chunkUpload: (...args: unknown[]) => chunkUploadMock(...args),
|
||
}))
|
||
|
||
describe('video material share page', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
global.fetch = jest.fn().mockResolvedValue(createVideoWaitResponse()) as jest.Mock
|
||
})
|
||
|
||
afterEach(() => {
|
||
jest.restoreAllMocks()
|
||
})
|
||
|
||
afterAll(() => {
|
||
global.fetch = originalFetch
|
||
})
|
||
|
||
test('保护期内保留分享页并明确提示同一链接稍后可用', async () => {
|
||
render(<SharePage />)
|
||
|
||
expect(await screen.findByRole('heading', { name: '视频预计 12 天后开放' })).toBeInTheDocument()
|
||
expect(screen.getByText('分享链接已生效')).toBeInTheDocument()
|
||
expect(screen.getByText('还需约 12 天')).toBeInTheDocument()
|
||
expect(screen.getByText('无需重新分享')).toBeInTheDocument()
|
||
expect(screen.getByRole('progressbar', { name: '视频分享保护期进度' })).toHaveAttribute(
|
||
'aria-valuetext',
|
||
'预计还需 12 天开放',
|
||
)
|
||
expect(screen.getByRole('button', { name: '重新检查' })).toBeInTheDocument()
|
||
expect(mockPush).not.toHaveBeenCalled()
|
||
expect(global.fetch).toHaveBeenCalledWith(
|
||
'/api/materials/share?id=video-material-1',
|
||
{ cache: 'no-store' },
|
||
)
|
||
})
|
||
|
||
test('重新检查时保留当前页面并提供明确的进行中反馈', async () => {
|
||
const user = userEvent.setup()
|
||
let resolveRefresh: ((value: ReturnType<typeof createVideoWaitResponse>) => void) | undefined
|
||
|
||
render(<SharePage />)
|
||
await screen.findByRole('heading', { name: '视频预计 12 天后开放' })
|
||
|
||
;(global.fetch as jest.Mock).mockImplementationOnce(
|
||
() => new Promise(resolve => {
|
||
resolveRefresh = resolve
|
||
}),
|
||
)
|
||
|
||
await user.click(screen.getByRole('button', { name: '重新检查' }))
|
||
|
||
expect(screen.getByRole('button', { name: '正在检查…' })).toBeDisabled()
|
||
expect(screen.getByText('正在确认视频是否已经开放')).toBeInTheDocument()
|
||
expect(screen.getByRole('heading', { name: '视频预计 12 天后开放' })).toBeInTheDocument()
|
||
|
||
resolveRefresh?.(createVideoWaitResponse())
|
||
expect(await screen.findByRole('button', { name: '重新检查' })).toBeEnabled()
|
||
})
|
||
})
|
||
|
||
describe('certificate report share page', () => {
|
||
afterAll(() => {
|
||
global.fetch = originalFetch
|
||
})
|
||
|
||
test.each([false, true])('详情展示证书字段、完整说明并按权限提供修改日期入口 (%s)', async (canEdit) => {
|
||
global.fetch = jest.fn().mockResolvedValue(createJsonResponse({
|
||
id: 'video-material-1',
|
||
userId: 'user-1',
|
||
previewUrl: '',
|
||
productModel: '',
|
||
reportTitle: '板材检测报告',
|
||
reportKind: '原材料',
|
||
certificateExpiresOn: '2028-09-23T00:00:00.000Z',
|
||
certificateTestedOn: '2027-09-23T00:00:00.000Z',
|
||
certificateNumber: 'CMA-2026-001',
|
||
certificateUsageInstructions: '使用前核对主体',
|
||
capabilities: { canEdit, canFavorite: false, canDelete: false },
|
||
reportUsageRules: '第一段\n第二段',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
platformName: '品牌',
|
||
materialType: '证书-检测报告',
|
||
authorName: '上传人',
|
||
zipFileName: '',
|
||
zipFileSize: '0',
|
||
downloadCount: 0,
|
||
likeCount: 0,
|
||
displayImages: [],
|
||
createdAt: '2026-09-23T00:00:00.000Z',
|
||
assets: [],
|
||
})) as jest.Mock
|
||
|
||
render(<SharePage />)
|
||
expect(await screen.findByText('CMA-2026-001')).toBeInTheDocument()
|
||
expect(screen.getByText('2027-09-23')).toBeInTheDocument()
|
||
expect(screen.getByText('2028-09-23')).toBeInTheDocument()
|
||
expect(screen.queryByRole('heading', { name: '证书使用说明' })).not.toBeInTheDocument()
|
||
expect(screen.queryByText('使用前核对主体')).not.toBeInTheDocument()
|
||
expect(screen.getByRole('heading', { name: '检测报告使用规则说明' }).nextElementSibling?.textContent)
|
||
.toBe('第一段\n第二段')
|
||
if (canEdit) {
|
||
await userEvent.setup().click(screen.getByRole('button', { name: '修改日期' }))
|
||
expect(mockPush).toHaveBeenCalledWith('/edit/video-material-1')
|
||
} else {
|
||
expect(screen.queryByRole('button', { name: '修改日期' })).not.toBeInTheDocument()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('task detail share page change requests', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
showToastMock.mockReset()
|
||
chunkUploadMock.mockReset()
|
||
})
|
||
|
||
afterEach(() => {
|
||
jest.restoreAllMocks()
|
||
})
|
||
|
||
test('serializes all operation kinds while keeping each request inside one image group', async () => {
|
||
const user = userEvent.setup()
|
||
chunkUploadMock
|
||
.mockResolvedValueOnce({ uploadId: 'upload-replace-1' })
|
||
.mockResolvedValueOnce({ uploadId: 'upload-append-1' })
|
||
.mockResolvedValueOnce({ uploadId: 'upload-append-2' })
|
||
.mockResolvedValueOnce({ uploadId: 'upload-insert-1' })
|
||
|
||
const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
const url = String(input)
|
||
if (url === '/api/materials/share?id=video-material-1') {
|
||
return createTaskDetailMaterialResponse()
|
||
}
|
||
if (url === '/api/materials/material-1/assets/change-requests' && init?.method === 'POST') {
|
||
return createJsonResponse({ success: true, mode: 'applied' })
|
||
}
|
||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||
return createJsonResponse({ success: true, data: [] })
|
||
}
|
||
return createJsonResponse({ success: true })
|
||
})
|
||
global.fetch = fetchMock as jest.Mock
|
||
|
||
render(<SharePage />)
|
||
expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument()
|
||
|
||
await user.click(screen.getByRole('button', { name: '触发替换' }))
|
||
await user.upload(
|
||
screen.getByTestId('task-detail-replace-input'),
|
||
new File(['replace'], 'replace-main.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
|
||
await user.click(screen.getByRole('button', { name: '触发重命名' }))
|
||
const renameInput = await screen.findByRole('textbox', { name: '新文件名' })
|
||
await user.clear(renameInput)
|
||
await user.type(renameInput, 'detail-2-renamed')
|
||
await user.click(screen.getByRole('button', { name: '加入待提交清单' }))
|
||
|
||
await user.click(screen.getByRole('button', { name: '触发删除' }))
|
||
|
||
await user.click(screen.getByRole('button', { name: '触发追加主图' }))
|
||
await user.upload(
|
||
screen.getByTestId('task-detail-batch-input'),
|
||
[
|
||
new File(['append-1'], 'append-main-1.jpg', { type: 'image/jpeg' }),
|
||
new File(['append-2'], 'append-main-2.jpg', { type: 'image/jpeg' }),
|
||
],
|
||
)
|
||
|
||
await user.click(await screen.findByRole('button', { name: '提交主管确认 5 项' }))
|
||
|
||
await user.click(screen.getByRole('button', { name: '触发插入详情' }))
|
||
await user.upload(
|
||
screen.getByTestId('task-detail-batch-input'),
|
||
new File(['insert'], 'insert-detail.jpg', { type: 'image/jpeg' }),
|
||
)
|
||
await user.click(await screen.findByRole('button', { name: '提交主管确认 1 项' }))
|
||
|
||
const requestCalls = fetchMock.mock.calls.filter(
|
||
([url, init]) => String(url) === '/api/materials/material-1/assets/change-requests'
|
||
&& init?.method === 'POST',
|
||
)
|
||
expect(requestCalls).toHaveLength(2)
|
||
expect(requestCalls[0]?.[1]).toMatchObject({
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
})
|
||
|
||
const mainPayload = JSON.parse(String((requestCalls[0]?.[1] as RequestInit).body))
|
||
expect(mainPayload).toEqual({
|
||
operations: [
|
||
{ kind: 'replace', assetId: 'main-1', uploadId: 'upload-replace-1' },
|
||
{ kind: 'rename', assetId: 'main-2', newFileName: 'detail-2-renamed' },
|
||
{ kind: 'delete', assetId: 'main-3' },
|
||
{ kind: 'append', group: 'main', uploadId: 'upload-append-1' },
|
||
{ kind: 'append', group: 'main', uploadId: 'upload-append-2' },
|
||
],
|
||
})
|
||
const detailPayload = JSON.parse(String((requestCalls[1]?.[1] as RequestInit).body))
|
||
expect(detailPayload).toEqual({
|
||
operations: [
|
||
{
|
||
kind: 'insert',
|
||
group: 'detail',
|
||
uploadId: 'upload-insert-1',
|
||
afterAssetId: 'detail-1',
|
||
beforeAssetId: 'detail-2',
|
||
},
|
||
],
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(showToastMock).toHaveBeenCalledWith(
|
||
'已直接应用 1 项变更,下载包已同步更新',
|
||
{ variant: 'success' },
|
||
)
|
||
})
|
||
})
|
||
|
||
test('shows pending approval details when server returns pending_approval mode', async () => {
|
||
const user = userEvent.setup()
|
||
const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
const url = String(input)
|
||
if (url === '/api/materials/share?id=video-material-1') {
|
||
return createTaskDetailMaterialResponse()
|
||
}
|
||
if (url === '/api/materials/material-1/assets/change-requests' && init?.method === 'POST') {
|
||
return createJsonResponse({
|
||
success: true,
|
||
mode: 'pending_approval',
|
||
request: {
|
||
id: 'request-1',
|
||
status: 'pending_manager_approval',
|
||
managerName: '王主管',
|
||
expiresAt: '2026-09-01T12:00:00.000Z',
|
||
},
|
||
})
|
||
}
|
||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||
return createJsonResponse({ success: true, data: [] })
|
||
}
|
||
return createJsonResponse({ success: true })
|
||
})
|
||
global.fetch = fetchMock as jest.Mock
|
||
|
||
render(<SharePage />)
|
||
expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument()
|
||
|
||
await user.click(screen.getByRole('button', { name: '触发删除' }))
|
||
await user.click(await screen.findByRole('button', { name: '提交主管确认 1 项' }))
|
||
|
||
expect(await screen.findByText('变更已提交主管确认')).toBeInTheDocument()
|
||
expect(screen.getByText('经理:王主管')).toBeInTheDocument()
|
||
expect(screen.getByText('到期:2026-09-01 20:00')).toBeInTheDocument()
|
||
expect(screen.getByText('待主管确认')).toBeInTheDocument()
|
||
|
||
await user.click(screen.getByRole('button', { name: '查看审批详情' }))
|
||
expect(mockPush).toHaveBeenCalledWith('/task-detail-change-requests/request-1')
|
||
})
|
||
|
||
test('blocks switching image groups before opening another upload picker', async () => {
|
||
const user = userEvent.setup()
|
||
global.fetch = jest.fn(async (input: RequestInfo | URL) => {
|
||
const url = String(input)
|
||
if (url === '/api/materials/share?id=video-material-1') return createTaskDetailMaterialResponse()
|
||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||
return createJsonResponse({ success: true, data: [] })
|
||
}
|
||
return createJsonResponse({ success: true })
|
||
}) as jest.Mock
|
||
|
||
render(<SharePage />)
|
||
expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument()
|
||
|
||
await user.click(screen.getByRole('button', { name: '触发删除' }))
|
||
await user.click(screen.getByRole('button', { name: '触发插入详情' }))
|
||
|
||
expect(showToastMock).toHaveBeenCalledWith(
|
||
'待提交清单已有主图变更,请先提交或清空后再编辑详情图',
|
||
{ variant: 'error' },
|
||
)
|
||
})
|
||
|
||
test('restores the latest pending manager approval after refresh', async () => {
|
||
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
|
||
const url = String(input)
|
||
if (url === '/api/materials/share?id=video-material-1') {
|
||
return createTaskDetailMaterialResponse()
|
||
}
|
||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||
return createJsonResponse({
|
||
success: true,
|
||
data: [{
|
||
id: 'request-restored',
|
||
status: 'pending_manager_approval',
|
||
managerName: '赵主管',
|
||
expiresAt: '2026-09-02T12:00:00.000Z',
|
||
}],
|
||
})
|
||
}
|
||
return createJsonResponse({ success: true })
|
||
})
|
||
global.fetch = fetchMock as jest.Mock
|
||
|
||
render(<SharePage />)
|
||
|
||
expect(await screen.findByText('变更已提交主管确认')).toBeInTheDocument()
|
||
expect(screen.getByText('经理:赵主管')).toBeInTheDocument()
|
||
expect(screen.getByText('待主管确认')).toBeInTheDocument()
|
||
})
|
||
})
|