gongxiangp2/app/share/[id]/page.test.tsx

490 lines
17 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.

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()
})
})