449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
import { act, render, screen, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { MemoryRouter } from 'react-router-dom';
|
||
import { AdminReportBatchesPage } from './AdminReportBatchesPage';
|
||
import { AdminReportMaterialsPage } from './AdminReportMaterialsPage';
|
||
import { AdminReportRecordsPage } from './AdminReportRecordsPage';
|
||
import { AdminReportTasksPage } from './AdminReportTasksPage';
|
||
|
||
const { adminApi, clipboardWriteText } = vi.hoisted(() => ({
|
||
adminApi: {
|
||
changeReportTaskStatuses: vi.fn(),
|
||
downloadReportMaterialBatch: vi.fn(),
|
||
downloadReportMaterialBatchFile: vi.fn(),
|
||
exportSingleReportMaterial: vi.fn(),
|
||
getReportMaterialBatch: vi.fn(),
|
||
getSingleReportMaterialDetail: vi.fn(),
|
||
listPendingReportMaterials: vi.fn(),
|
||
listReportDetailsPage: vi.fn(),
|
||
listReportMaterialBatches: vi.fn(),
|
||
listReportMaterialBatchTasks: vi.fn(),
|
||
listReportRecordsPage: vi.fn(),
|
||
preflightReportMaterialBatch: vi.fn(),
|
||
},
|
||
clipboardWriteText: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('@/api/adminApi', () => ({ adminApi, fileDownloadUrl: (id: string) => `/api/files/${id}` }));
|
||
|
||
const task = (id: string) => ({
|
||
id,
|
||
tenantId: 'tenant-1',
|
||
signatureId: `signature-${id}`,
|
||
channelId: 'channel-1',
|
||
carrier: 'mobile',
|
||
reportType: 'signature',
|
||
status: 'pending',
|
||
createdAt: '2026-09-02T01:00:00.000Z',
|
||
updatedAt: '2026-09-02T01:00:00.000Z',
|
||
signature: {
|
||
id: `signature-${id}`,
|
||
name: `签名${id}`,
|
||
tenant: { id: 'tenant-1', name: '测试企业' },
|
||
application: { id: 'app-1', name: '测试应用' },
|
||
},
|
||
channel: { id: 'channel-1', name: '测试通道' },
|
||
});
|
||
|
||
describe('report workbench pages', () => {
|
||
beforeEach(() => {
|
||
Object.values(adminApi).forEach((method) => method.mockReset());
|
||
clipboardWriteText.mockReset().mockResolvedValue(undefined);
|
||
});
|
||
|
||
it('selects and clears every detail on the current page', async () => {
|
||
adminApi.listReportDetailsPage.mockResolvedValue({
|
||
items: [task('1'), task('2')],
|
||
total: 2,
|
||
page: 1,
|
||
pageSize: 25,
|
||
});
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportTasksPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
const selectPage = await screen.findByRole('button', { name: '全选当页' });
|
||
await user.click(selectPage);
|
||
expect(screen.getByRole('button', { name: '取消全选' })).toBeVisible();
|
||
expect(screen.getByRole('button', { name: '批量修改状态(2)' })).toBeEnabled();
|
||
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
|
||
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).toBeChecked());
|
||
|
||
await user.click(screen.getByRole('button', { name: '取消全选' }));
|
||
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
|
||
});
|
||
|
||
it('changes detail page size while retaining applied filters and clearing current-page selection', async () => {
|
||
adminApi.listReportDetailsPage.mockImplementation(async ({ page, pageSize }) => ({
|
||
items: [task(`${page}-1`), task(`${page}-2`)],
|
||
total: 258,
|
||
page,
|
||
pageSize,
|
||
}));
|
||
render(
|
||
<MemoryRouter initialEntries={['/admin/report-tasks?signatureId=signature-filter&scope=pending']}>
|
||
<AdminReportTasksPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
await screen.findByText('签名1-1');
|
||
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ page: 1, pageSize: 25, signatureId: 'signature-filter', status: 'pending' }),
|
||
);
|
||
const sizeControl = screen.getByLabelText(/^每页数量/);
|
||
expect(sizeControl.closest('.ui-pagination')).not.toBeNull();
|
||
await user.click(sizeControl);
|
||
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
|
||
'10 条/页',
|
||
'25 条/页',
|
||
'50 条/页',
|
||
'100 条/页',
|
||
]);
|
||
await user.click(screen.getByRole('option', { name: '25 条/页' }));
|
||
await user.type(screen.getByRole('textbox', { name: '企业/应用/通道/报备对象' }), '测试企业');
|
||
await user.click(screen.getByRole('button', { name: '查询' }));
|
||
await user.click(screen.getByRole('button', { name: '下一页' }));
|
||
await screen.findByText('签名2-1');
|
||
await user.click(screen.getByRole('button', { name: '全选当页' }));
|
||
expect(screen.getByRole('button', { name: '批量修改状态(2)' })).toBeEnabled();
|
||
|
||
await user.click(sizeControl);
|
||
await user.click(screen.getByRole('option', { name: '100 条/页' }));
|
||
await screen.findByText('签名1-1');
|
||
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: '测试企业',
|
||
signatureId: 'signature-filter',
|
||
status: 'pending',
|
||
}),
|
||
);
|
||
expect(screen.getByRole('button', { name: '批量修改状态(0)' })).toBeDisabled();
|
||
expect(screen.getByRole('spinbutton', { name: '跳转页码' })).toHaveAttribute('max', '3');
|
||
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
|
||
|
||
for (const size of [10, 50, 25]) {
|
||
await user.click(sizeControl);
|
||
await user.click(screen.getByRole('option', { name: `${size} 条/页` }));
|
||
await waitFor(() =>
|
||
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ page: 1, pageSize: size, keyword: '测试企业' }),
|
||
),
|
||
);
|
||
}
|
||
});
|
||
|
||
it('keeps the latest detail page size result when an earlier request finishes last', async () => {
|
||
const initialPage = { items: [task('old-25')], total: 258, page: 1, pageSize: 25 };
|
||
const latestPage = { items: [task('new-100')], total: 258, page: 1, pageSize: 100 };
|
||
let resolveInitial!: (value: typeof initialPage) => void;
|
||
let resolveLatest!: (value: typeof latestPage) => void;
|
||
adminApi.listReportDetailsPage
|
||
.mockImplementationOnce(() => new Promise((resolve) => (resolveInitial = resolve)))
|
||
.mockImplementationOnce(() => new Promise((resolve) => (resolveLatest = resolve)));
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportTasksPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
await user.click(screen.getByLabelText(/^每页数量/));
|
||
await user.click(screen.getByRole('option', { name: '100 条/页' }));
|
||
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
|
||
await act(async () => resolveLatest(latestPage));
|
||
expect(screen.getByText('签名new-100')).toBeVisible();
|
||
await act(async () => resolveInitial(initialPage));
|
||
expect(screen.queryByText('签名old-25')).not.toBeInTheDocument();
|
||
expect(screen.getByText('签名new-100')).toBeVisible();
|
||
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('100 条/页');
|
||
});
|
||
|
||
it('keeps the current detail request failure visible without replacing it with a stale failure', async () => {
|
||
let rejectInitial!: (error: Error) => void;
|
||
let rejectLatest!: (error: Error) => void;
|
||
adminApi.listReportDetailsPage
|
||
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectInitial = reject)))
|
||
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectLatest = reject)));
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportTasksPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
await user.click(screen.getByLabelText(/^每页数量/));
|
||
await user.click(screen.getByRole('option', { name: '100 条/页' }));
|
||
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
|
||
await act(async () => rejectLatest(new Error('当前分页加载失败')));
|
||
expect(screen.getByText('当前分页加载失败')).toBeVisible();
|
||
await act(async () => rejectInitial(new Error('已过期的分页请求失败')));
|
||
expect(screen.getByText('当前分页加载失败')).toBeVisible();
|
||
expect(screen.queryByText('已过期的分页请求失败')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('uses the shared current-page selection button in the material pool heading', async () => {
|
||
const material = {
|
||
id: 'signature:signature-1',
|
||
tenantId: 'tenant-1',
|
||
signatureId: 'signature-1',
|
||
applicationId: 'app-1',
|
||
reportType: 'signature',
|
||
name: '测试签名',
|
||
detail: '测试用途',
|
||
materialVersion: 1,
|
||
changedAt: '2026-09-03T01:00:00.000Z',
|
||
tenant: { id: 'tenant-1', name: '测试企业' },
|
||
application: { id: 'app-1', name: '测试应用' },
|
||
};
|
||
adminApi.listPendingReportMaterials.mockResolvedValue({ items: [material], total: 1, page: 1, pageSize: 20 });
|
||
adminApi.preflightReportMaterialBatch.mockResolvedValue({
|
||
eligible: true,
|
||
eligibleTargetCount: 1,
|
||
skippedTargetCount: 0,
|
||
items: [
|
||
{
|
||
id: material.id,
|
||
eligible: true,
|
||
blockedReasons: [],
|
||
targets: [{ eligible: true }],
|
||
},
|
||
],
|
||
});
|
||
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportMaterialsPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
await user.click(await screen.findByRole('button', { name: '全选当页' }));
|
||
expect(screen.getByRole('button', { name: '取消全选' })).toBeVisible();
|
||
expect(screen.getByRole('button', { name: '预检并生成(1)' })).toBeEnabled();
|
||
expect(screen.getByRole('checkbox', { name: '选择测试签名' })).toBeChecked();
|
||
expect(screen.queryByText('选择本页全部可生成资料')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('uses explicit colors and text for material pool work states', async () => {
|
||
const materials = [
|
||
['pending', '待生成'],
|
||
['partial', '部分可生成'],
|
||
['incomplete', '资料不完整'],
|
||
['abandoned', '全部放弃'],
|
||
['no-route', '无有效通道'],
|
||
['generated', '已生成'],
|
||
].map(([id]) => ({
|
||
id: `signature:${id}`,
|
||
tenantId: 'tenant-1',
|
||
signatureId: `signature-${id}`,
|
||
applicationId: 'app-1',
|
||
reportType: 'signature' as const,
|
||
name: `资料${id}`,
|
||
materialVersion: 2,
|
||
changedAt: '2026-09-03T01:00:00.000Z',
|
||
tenant: { id: 'tenant-1', name: '测试企业' },
|
||
application: { id: 'app-1', name: '测试应用' },
|
||
}));
|
||
const target = (eligible: boolean, blockedReasons: string[] = []) => ({ eligible, blockedReasons });
|
||
adminApi.listPendingReportMaterials.mockResolvedValue({
|
||
items: materials,
|
||
total: materials.length,
|
||
page: 1,
|
||
pageSize: 20,
|
||
});
|
||
adminApi.preflightReportMaterialBatch.mockResolvedValue({
|
||
eligible: true,
|
||
eligibleTargetCount: 2,
|
||
skippedTargetCount: 5,
|
||
items: [
|
||
{ id: 'signature:pending', eligible: true, blockedReasons: [], targets: [target(true)] },
|
||
{
|
||
id: 'signature:partial',
|
||
eligible: true,
|
||
blockedReasons: ['缺少必填字段:营业执照'],
|
||
targets: [target(true), target(false, ['缺少必填字段:营业执照'])],
|
||
},
|
||
{
|
||
id: 'signature:incomplete',
|
||
eligible: false,
|
||
blockedReasons: ['缺少必填字段:营业执照'],
|
||
targets: [target(false, ['缺少必填字段:营业执照'])],
|
||
},
|
||
{
|
||
id: 'signature:abandoned',
|
||
eligible: false,
|
||
blockedReasons: ['该通道报备明细已放弃报备'],
|
||
targets: [target(false, ['该通道报备明细已放弃报备'])],
|
||
},
|
||
{ id: 'signature:no-route', eligible: false, blockedReasons: ['当前应用没有启用且可路由的通道'], targets: [] },
|
||
{
|
||
id: 'signature:generated',
|
||
eligible: false,
|
||
blockedReasons: ['同一资料版本已在批次 RB-1 生成'],
|
||
targets: [target(false, ['同一资料版本已在批次 RB-1 生成'])],
|
||
},
|
||
],
|
||
});
|
||
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportMaterialsPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
expect(await screen.findByText('V2待生成')).toHaveClass('ui-tag--warning');
|
||
expect(screen.getByText('V2部分可生成')).toHaveClass('ui-tag--warning');
|
||
expect(screen.getByText('V2资料不完整')).toHaveClass('ui-tag--danger');
|
||
expect(screen.getByText('全部放弃')).toHaveClass('ui-tag--neutral');
|
||
expect(screen.getByText('无有效通道')).toHaveClass('ui-tag--neutral');
|
||
expect(screen.getByText('V2已生成')).toHaveClass('ui-tag--success');
|
||
screen.getAllByText('缺少必填字段:营业执照').forEach((message) => expect(message).toHaveClass('status-danger'));
|
||
});
|
||
|
||
it('keeps the report record list compact while retaining full details in the dialog', async () => {
|
||
adminApi.listReportRecordsPage.mockResolvedValue({
|
||
items: [
|
||
{
|
||
id: 'record-1',
|
||
taskId: 'report-task-with-a-long-identifier-1',
|
||
channelId: 'channel-1',
|
||
action: 'manual_status_change',
|
||
statusBefore: 'pending',
|
||
statusAfter: 'approved',
|
||
reason: '通道已确认报备通过',
|
||
sourceEntry: 'report_task',
|
||
createdAt: '2026-09-03 15:30:00',
|
||
channel: { id: 'channel-1', name: '测试通道' },
|
||
operator: { id: 'operator-1', username: 'operator', displayName: '运营一' },
|
||
task: task('record-1'),
|
||
},
|
||
],
|
||
total: 1,
|
||
page: 1,
|
||
pageSize: 10,
|
||
});
|
||
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportRecordsPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
expect(await screen.findByRole('columnheader', { name: '变更对象' })).toBeVisible();
|
||
expect(screen.getByRole('columnheader', { name: '变更动作' })).toBeVisible();
|
||
expect(screen.getByRole('columnheader', { name: '操作人 / 时间' })).toBeVisible();
|
||
expect(screen.queryByRole('columnheader', { name: '备注' })).not.toBeInTheDocument();
|
||
expect(screen.getByText('报备任务修改')).toBeVisible();
|
||
|
||
await userEvent.click(screen.getByRole('button', { name: '详情' }));
|
||
expect((await screen.findAllByText('通道已确认报备通过')).length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('shows and copies the channel brief returned by the batch detail API', async () => {
|
||
const batch = {
|
||
id: 'batch-1',
|
||
batchNo: 'RB20260902090000TEST',
|
||
status: 'completed',
|
||
selectedCount: 1,
|
||
channelCount: 1,
|
||
fileCount: 1,
|
||
reportTotal: 1,
|
||
reportingCount: 0,
|
||
successCount: 0,
|
||
failedCount: 0,
|
||
successRate: 0,
|
||
createdAt: '2026-09-02T01:00:00.000Z',
|
||
exportFiles: [],
|
||
};
|
||
const content =
|
||
'尊敬的供应商您好,今天是2026年9月2日,辛苦报备以下签名或引流信息:\n' +
|
||
'1.短信签名:【测试签名】,短信内容:【测试签名】您本次提交的验证码1234;\n' +
|
||
'报备材料在表格里,报备批次号RB20260902090000TEST。';
|
||
adminApi.listReportMaterialBatches.mockResolvedValue({ items: [batch], total: 1, page: 1, pageSize: 20 });
|
||
adminApi.getReportMaterialBatch.mockResolvedValue({
|
||
...batch,
|
||
briefs: [
|
||
{
|
||
channelId: 'channel-1',
|
||
channelName: '测试通道',
|
||
fileId: 'file-1',
|
||
fileName: '测试通道.xlsx',
|
||
itemCount: 1,
|
||
content,
|
||
},
|
||
],
|
||
});
|
||
adminApi.listReportMaterialBatchTasks.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 100 });
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportBatchesPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: { writeText: clipboardWriteText },
|
||
});
|
||
await user.click(await screen.findByRole('button', { name: '报备文件导出' }));
|
||
expect(await screen.findByText('测试通道')).toBeVisible();
|
||
expect(screen.getByRole('button', { name: '导出格式' })).toHaveTextContent('系统 Excel 文件');
|
||
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
|
||
expect(screen.getByRole('button', { name: '全部下载' })).toBeEnabled();
|
||
await user.click(screen.getByRole('button', { name: '复制简报' }));
|
||
await waitFor(() => expect(clipboardWriteText).toHaveBeenCalledWith(content));
|
||
expect(screen.getByRole('button', { name: '已复制' })).toBeVisible();
|
||
});
|
||
|
||
it('opens batch details in a drawer and keeps status controls in a nested dialog', async () => {
|
||
const batch = {
|
||
id: 'batch-2',
|
||
batchNo: 'RB-DRAWER',
|
||
status: 'completed',
|
||
selectedCount: 2,
|
||
channelCount: 1,
|
||
fileCount: 1,
|
||
reportTotal: 2,
|
||
reportingCount: 1,
|
||
successCount: 1,
|
||
failedCount: 0,
|
||
successRate: 0.5,
|
||
createdAt: '2026-09-03T01:00:00.000Z',
|
||
exportFiles: [],
|
||
briefs: [],
|
||
};
|
||
adminApi.listReportMaterialBatches.mockResolvedValue({ items: [batch], total: 1, page: 1, pageSize: 20 });
|
||
adminApi.getReportMaterialBatch.mockResolvedValue(batch);
|
||
adminApi.listReportMaterialBatchTasks.mockResolvedValue({
|
||
items: [task('1'), task('2')],
|
||
total: 2,
|
||
page: 1,
|
||
pageSize: 100,
|
||
});
|
||
render(
|
||
<MemoryRouter>
|
||
<AdminReportBatchesPage />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
const user = userEvent.setup();
|
||
await user.click(await screen.findByRole('button', { name: '打开明细' }));
|
||
const drawer = await screen.findByRole('dialog', { name: '批次明细 · RB-DRAWER' });
|
||
expect(drawer).toHaveClass('report-batch-drawer');
|
||
await user.click(screen.getByRole('checkbox', { name: '全选批次明细' }));
|
||
expect(screen.getByRole('button', { name: '批量修改报备状态' })).toBeEnabled();
|
||
expect(screen.getAllByRole('button', { name: '修改状态' })).toHaveLength(2);
|
||
await user.click(screen.getByRole('button', { name: '批量修改报备状态' }));
|
||
expect(await screen.findByRole('dialog', { name: '批量修改 2 条报备状态' })).toBeVisible();
|
||
expect(screen.getByLabelText('修改后的状态')).toBeVisible();
|
||
expect(screen.queryByRole('button', { name: '导出本条' })).not.toBeInTheDocument();
|
||
});
|
||
});
|