Files
Genarrative/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx
T
lhk229 a344686b59
Project CI / AI game creator shell Rust smoke (push) Successful in 1m44s
Project CI / AI game creator shell Rust crates (push) Successful in 1m9s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
为后台项目工程新增按需全量同步时间排序 (#546)
新增按钮读取所选渠道全部项目并按同步时间降序、用户与项目 ID 升序展示
复用本地分页并支持全量刷新、恢复默认顺序、进度提示与取消读取
为全量读取增加超时、数量和游标边界,失败时保留原结果
补充三项关键交互测试并同步技术方案、运维说明与共享记忆

Reviewed-on: #546
2026-10-01 11:51:10 +08:00

670 lines
25 KiB
TypeScript

// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type {
AdminProjectSnapshotEntry,
AdminProjectSnapshotListResponse,
} from '../api/adminApiTypes';
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
vi.mock('../api/adminApiClient', async () => ({
...(await vi.importActual<typeof import('../api/adminApiClient')>(
'../api/adminApiClient',
)),
downloadAdminProjectSnapshot: vi.fn(),
getAdminProjectSnapshotChannels: vi.fn(),
listAdminProjectSnapshots: vi.fn(),
}));
const entry: AdminProjectSnapshotEntry = {
userId: 'user-1',
projectId: 'project-1',
projectName: '三消工程',
syncRevision: 3,
syncedAtMs: 1_700_000_000_000,
fileCount: 12,
totalBytes: 2048,
status: 'ready',
channel: 'dev',
authorDisplayName: '陶泥作者',
authorPublicUserCode: 'SY-00000007',
};
beforeEach(() => {
vi.mocked(listAdminProjectSnapshots)
.mockReset()
.mockResolvedValue({ items: [entry], nextCursor: null });
vi.mocked(getAdminProjectSnapshotChannels)
.mockReset()
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
vi.mocked(downloadAdminProjectSnapshot).mockReset();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => {
const olderProjects = Array.from({ length: 22 }, (_, index) => ({
...entry,
projectId: `older-${index}`,
projectName: `旧项目 ${index}`,
syncedAtMs: entry.syncedAtMs + index,
}));
const tiedProjects = [
{ ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' },
{ ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' },
{ ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' },
].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 }));
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
.mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' })
// 后端有界扫描可能返回带下一游标的空页,不能提前结束。
.mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' })
.mockResolvedValueOnce({
items: [
...tiedProjects,
{
...olderProjects[0]!,
projectName: '更新项目',
syncedAtMs: entry.syncedAtMs + 200,
},
],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByRole('button', { name: '恢复默认顺序' });
const rowNames = () =>
screen
.getAllByRole('row')
.map((row) => row.querySelector('td strong')?.textContent)
.filter(Boolean);
expect(rowNames().slice(0, 4)).toEqual([
'更新项目',
'同秒甲一',
'同秒甲二',
'同秒乙',
]);
expect(rowNames()).toHaveLength(20);
expect(screen.getByRole('navigation').textContent).toContain('共 25 个');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
4,
'token',
{ cursor: 'scan-3', limit: 100, channel: 'dev' },
expect.any(AbortSignal),
);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect(rowNames()).toHaveLength(5);
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
expect(rowNames()[0]).toBe('更新项目');
fireEvent.change(screen.getByLabelText('每页条数'), {
target: { value: '50' },
});
expect(rowNames()).toHaveLength(25);
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4);
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [{ ...entry, projectName: '刷新后的项目' }],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('刷新后的项目');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
5,
'token',
{ cursor: null, limit: 100, channel: 'dev' },
expect.any(AbortSignal),
);
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
6,
'token',
{ cursor: null, limit: 50, channel: 'dev' },
expect.any(AbortSignal),
);
});
test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => {
const projects = Array.from({ length: 45 }, (_, index) => ({
...entry,
projectId: `sorted-${index}`,
projectName: `排序项目 ${index}`,
syncedAtMs: entry.syncedAtMs - index,
}));
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockResolvedValueOnce({ items: projects, nextCursor: null })
.mockRejectedValueOnce(new Error('恢复失败'))
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '默认第一页' }],
nextCursor: 'default-next',
})
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '默认第二页' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByText('排序项目 0');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
await screen.findByText('恢复失败');
expect(screen.getByText('排序项目 20')).toBeTruthy();
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
expect(screen.getByRole('navigation').textContent).toContain('共 45 个');
expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect(screen.getByText('排序项目 40')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
expect(screen.getByText('排序项目 20')).toBeTruthy();
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3);
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
await screen.findByText('默认第一页');
expect(screen.getByRole('navigation').textContent).toContain('第 1 页');
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
4,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('默认第二页');
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
5,
'token',
{ cursor: 'default-next', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
});
test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '部分读取项目' }],
nextCursor: 'scan-next',
})
.mockRejectedValueOnce(new Error('全量读取失败'));
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByText('全量读取失败');
expect(screen.getByText('三消工程')).toBeTruthy();
expect(screen.queryByText('部分读取项目')).toBeNull();
expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull();
expect(
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
).toBe(false);
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [{ ...entry, projectName: '完整排序项目' }],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
await screen.findByText('完整排序项目');
vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce(
new Error('刷新读取失败'),
);
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('刷新读取失败');
expect(screen.getByText('完整排序项目')).toBeTruthy();
expect(screen.getByRole('navigation').textContent).toContain('共 1 个');
});
test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => {
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
defaultChannel: 'dev',
channels: ['dev', 'release'],
});
let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void;
let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void;
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockImplementationOnce(
() =>
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
finishCancelled = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
finishOldChannel = resolve;
}),
)
.mockResolvedValueOnce({
items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock
.calls[1]![2]!;
fireEvent.click(screen.getByRole('button', { name: '取消排序' }));
expect(cancelledSignal.aborted).toBe(true);
await act(async () => {
finishCancelled({
items: [{ ...entry, projectName: '已取消项目' }],
nextCursor: null,
});
});
expect(screen.getByText('三消工程')).toBeTruthy();
expect(screen.queryByText('已取消项目')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock
.calls[2]![2]!;
fireEvent.change(screen.getByLabelText('项目工程渠道'), {
target: { value: 'release' },
});
await screen.findByText('正式渠道项目');
expect(oldChannelSignal.aborted).toBe(true);
await act(async () => {
finishOldChannel({
items: [{ ...entry, projectName: '旧渠道项目' }],
nextCursor: null,
});
});
expect(screen.getByText('正式渠道项目')).toBeTruthy();
expect(screen.queryByText('旧渠道项目')).toBeNull();
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
});
test('按项目展示完整性并限制未完成工程下载', async () => {
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
items: [
entry,
{
...entry,
projectId: 'partial-project',
projectName: '未完成工程',
status: 'partial',
},
{
...entry,
projectId: 'legacy-project',
projectName: null,
status: 'unverified',
},
],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const completeRow = (await screen.findByText('三消工程')).closest('tr')!;
expect(within(completeRow).getByText('2 KiB')).toBeTruthy();
expect(
within(completeRow)
.getByRole('button', { name: '下载完整工程' })
.hasAttribute('disabled'),
).toBe(false);
expect(
screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.getByText('完整性未知')).toBeTruthy();
expect(
screen
.getByRole('button', { name: '下载已存文件' })
.hasAttribute('disabled'),
).toBe(false);
});
test('按游标翻页回到上一页时复用已取得的游标', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
})
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
const pagination = await screen.findByRole('navigation', {
name: '项目工程分页',
});
expect(pagination.textContent).toContain('第 1 页');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
2,
'token',
{ cursor: 'page-2', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(screen.queryByText('三消工程')).toBeNull();
expect(pagination.textContent).toContain('第 2 页');
expect(
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(pagination.textContent).toContain('第 1 页');
});
test('切换每页条数从第一页按新条数重新加载', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
})
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
fireEvent.change(screen.getByLabelText('每页条数'), {
target: { value: '50' },
});
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 50, channel: 'dev' },
expect.any(AbortSignal),
);
// 重新加载时页脚会重建,必须重新取节点再断言。
expect(
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
).toContain('第 1 页');
});
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: null,
})
.mockRejectedValueOnce(new Error('翻页读取失败'));
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByRole('alert');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: 'page-2', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
expect(screen.getByText('第二工程')).toBeTruthy();
expect(screen.queryByText('暂无已上传项目')).toBeNull();
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('暂无已上传项目');
expect(screen.queryByRole('alert')).toBeNull();
});
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const row = (await screen.findByText('三消工程')).closest('tr')!;
expect(within(row).getByText('陶泥作者')).toBeTruthy();
expect(within(row).getByText('SY-00000007')).toBeTruthy();
expect(
within(row).getByRole('button', { name: '查看用户信息' }),
).toBeTruthy();
expect(within(row).queryByText('user-1')).toBeNull();
});
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
defaultChannel: 'release',
channels: ['dev', 'release'],
});
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockResolvedValueOnce({
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
'token',
{ cursor: null, limit: 20, channel: 'release' },
expect.any(AbortSignal),
);
const channelSelect = screen.getByLabelText('项目工程渠道');
expect((channelSelect as HTMLSelectElement).value).toBe('release');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第 2 页,本页 1 个项目');
fireEvent.change(channelSelect, { target: { value: 'dev' } });
await screen.findByText('dev 工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
});
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
const createObjectURL = vi.fn(() => 'blob:archive');
const revokeObjectURL = vi.fn();
vi.stubGlobal(
'URL',
class extends URL {
static createObjectURL = createObjectURL;
static revokeObjectURL = revokeObjectURL;
},
);
let savedFilename = '';
let savedHref = '';
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
savedFilename = this.download;
savedHref = this.href;
});
const blob = new Blob(['PK'], { type: 'application/zip' });
vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({
blob,
filename: '三消工程-r3.zip',
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const button = await screen.findByRole('button', { name: '下载完整工程' });
vi.useFakeTimers();
await act(async () => {
fireEvent.click(button);
});
expect(savedFilename).toBe('三消工程-r3.zip');
expect(savedHref).toBe('blob:archive');
expect(createObjectURL).toHaveBeenCalledWith(blob);
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
'token',
'dev',
'user-1',
'project-1',
expect.any(AbortSignal),
);
act(() => vi.advanceTimersByTime(1000));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
});
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
(_token, _channel, _user, _project, signal) =>
new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
const view = render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
expect(
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
).toBe(true);
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2];
view.unmount();
expect(signal?.aborted).toBe(true);
});
test('下载登录失效走现有会话处理,403 错误保留页面', async () => {
const onUnauthorized = vi.fn();
vi.mocked(downloadAdminProjectSnapshot)
.mockRejectedValueOnce(
new AdminApiError({ status: 403, message: '无项目工程权限' }),
)
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '已过期' }),
);
render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
expect(await screen.findByText('无项目工程权限')).toBeTruthy();
expect(onUnauthorized).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '下载完整工程' }));
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => {
const onUnauthorized = vi.fn();
vi.mocked(listAdminProjectSnapshots)
.mockRejectedValueOnce(new Error('清单存储不可用'))
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '已过期' }),
);
render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
);
await screen.findByText('清单存储不可用');
expect(screen.queryByText('暂无已上传项目')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
let finishOldRequest!: (value: {
items: AdminProjectSnapshotEntry[];
nextCursor: null;
}) => void;
vi.mocked(listAdminProjectSnapshots)
.mockReturnValueOnce(
new Promise((resolve) => {
finishOldRequest = resolve;
}),
)
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '新账号工程' }],
nextCursor: null,
});
const onUnauthorized = vi.fn();
const view = render(
<AdminProjectSnapshotsPage
token="old-token"
onUnauthorized={onUnauthorized}
/>,
);
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
await waitFor(() =>
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
);
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
view.rerender(
<AdminProjectSnapshotsPage
token="new-token"
onUnauthorized={onUnauthorized}
/>,
);
await screen.findByText('新账号工程');
expect(oldSignal?.aborted).toBe(true);
await act(async () => {
finishOldRequest({ items: [entry], nextCursor: null });
});
expect(screen.queryByText('三消工程')).toBeNull();
expect(screen.getByText('新账号工程')).toBeTruthy();
});
test('卸载后完成的下载不会创建浏览器文件', async () => {
let finishDownload!: (value: { blob: Blob; filename: string }) => void;
vi.mocked(downloadAdminProjectSnapshot).mockReturnValue(
new Promise((resolve) => {
finishDownload = resolve;
}),
);
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
const view = render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
view.unmount();
expect(signal?.aborted).toBe(true);
await act(async () => {
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
});
expect(click).not.toHaveBeenCalled();
});