Files
lislgosms/src/apps/admin/ReportWorkbenchPages.test.tsx
T

449 lines
18 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 { 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();
});
});