Files
Genarrative/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx
T
suzmii 072284b846 后台游戏管理与审核页排版修复,安全下架入口收敛到游戏管理页
- 游戏管理页:下架改为独立面板(原因必填、校验提示、仍走二次确认),操作列与作者列排版调整,gameId 省略号加 title
- 游戏管理页测试:拆成空原因不提交、面板取消不请求、面板确认后仍需二次确认三条
- 审核页:移除行内游戏级安全下架及其状态与二次确认接线,理由弹窗只服务拒绝,gameId 截断加 title
- 审核页测试:删除两条安全下架用例,替换为行内无该动作、microsZ 时间列渲染、窄视口 gameId 截断
- 新增 adminGameDistributionReview.css:审核页专属列宽与动作列 nowrap,含 1366/980 两档断点
- admin-web admin.css:新增游戏管理表格 5 条排版规则
- shared AdminFormat:新增 normalizeAdminTimestamp,兼容后端 seconds.microsZ 时间口径
- shared AdminFormat 测试:新增 5 条覆盖微秒口径、补零、ISO 与 epoch 兼容、非法值回落
- shared admin.css:admin-status 改为 nowrap
- 文档:后台游戏审核详情计划加取代标注,说明审核页只承载版本级动作
2026-10-04 00:17:21 +08:00

432 lines
14 KiB
TypeScript

/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { AdminGameManagementPage } from './AdminGameManagementPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionGames: vi.fn(),
restoreAdminGameDistributionGame: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const version: AdminGameDistributionGameVersionEntry = {
versionId: 'version-3',
gameId: 'game_1',
versionNumber: 3,
status: 'published',
reviewReason: '测试原因',
packageBytes: 2048,
packageSha256:
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
reviewedAt: '2026-09-20T09:00:00Z',
publishedAt: '2026-09-20T10:00:00Z',
entryUrl: 'https://game.example.com/game_1',
};
const publishedGame: AdminGameDistributionGameEntry = {
gameId: 'game_1',
title: '测试游戏',
author: {
id: 'user_1',
name: '作者甲',
avatarUrl: 'https://example.com/avatar.png',
},
status: 'published',
versionCount: 21,
playCount: 345,
activeVersionId: 'version-3',
publicationRevision: 4,
createdAt: '2026-09-18T08:00:00Z',
updatedAt: '2026-09-20T10:00:00Z',
deletedAt: null,
versions: [version],
};
const suspendedGame: AdminGameDistributionGameEntry = {
gameId: 'game_2',
title: '下架游戏',
author: {
id: 'user_2',
name: '作者乙',
avatarUrl: null,
},
status: 'suspended',
versionCount: 2,
playCount: 8,
activeVersionId: null,
publicationRevision: 7,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-21T08:00:00Z',
deletedAt: null,
versions: [],
};
const deletedGame: AdminGameDistributionGameEntry = {
gameId: 'game_3',
title: '已删除作品',
author: {
id: 'user_3',
name: '作者丙',
avatarUrl: null,
},
status: 'deleted',
versionCount: 1,
playCount: 3,
activeVersionId: null,
publicationRevision: 2,
createdAt: '2026-09-17T08:00:00Z',
updatedAt: '2026-09-22T08:00:00Z',
deletedAt: '2026-09-22T08:00:00Z',
versions: [],
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionGames)
.mockReset()
.mockResolvedValue({ games: [publishedGame] });
vi.mocked(restoreAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: suspendedGame.gameId,
title: suspendedGame.title,
status: 'published',
publicationRevision: 8,
},
replayed: false,
});
vi.mocked(suspendAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: publishedGame.gameId,
title: publishedGame.title,
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
});
test('有评价权限时游戏管理提供带gameId的评价入口', async () => {
window.history.replaceState(null, '', '#game-management');
render(
<AdminGameManagementPage
token="token"
canManageReviews
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '查看评价' }));
expect(window.location.hash).toBe('#game-reviews?gameId=game_1');
window.history.replaceState(null, '', '/');
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test('列表展示作者头像、状态、版本数和游玩数', async () => {
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
games: [publishedGame, suspendedGame],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const publishedRow = (await screen.findByText('测试游戏')).closest('tr')!;
expect(within(publishedRow).getByText('作者甲')).toBeTruthy();
const avatar = within(publishedRow).getByRole('img', {
name: '作者甲 头像',
});
expect(avatar.getAttribute('src')).toBe('https://example.com/avatar.png');
expect(within(publishedRow).getByText('已公开')).toBeTruthy();
expect(within(publishedRow).getByText('21')).toBeTruthy();
expect(within(publishedRow).getByText('345')).toBeTruthy();
const suspendedRow = (await screen.findByText('下架游戏')).closest('tr')!;
expect(within(suspendedRow).getByText('作者乙')).toBeTruthy();
expect(suspendedRow.querySelector('.admin-user-avatar')?.textContent).toBe(
'作',
);
expect(within(suspendedRow).getByText('已下架')).toBeTruthy();
});
test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列表', async () => {
vi.mocked(listAdminGameDistributionGames)
.mockResolvedValueOnce({ games: [suspendedGame] })
.mockResolvedValueOnce({
games: [{ ...suspendedGame, status: 'published' }],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('下架游戏');
expect(screen.queryByRole('button', { name: '下架' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(restoreAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(restoreAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(restoreAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_2');
expect(String(idempotencyKey)).toContain('game_2');
expect(payload).toEqual({ expectedPublicationRevision: 7 });
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
);
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
});
test('下架原因在独立面板内填写:空原因不提交并给出提示', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
// 操作列不再内嵌输入框,点按钮才打开面板。
expect(screen.queryByLabelText('下架原因')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
const reasonInput = within(dialog).getByLabelText('下架原因');
expect((reasonInput as HTMLTextAreaElement).value).toBe('');
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
expect(await within(dialog).findByText('请填写下架原因')).toBeTruthy();
expect(reasonInput.getAttribute('aria-invalid')).toBe('true');
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '确认操作' })).toBeNull();
});
test('下架面板取消不触发请求', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
test('下架面板确认后仍需二次确认,提交原因与当前公开修订号', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
// 面板确认只进入原有的写入二次确认,此时还不能发请求。
const confirmDialog = await screen.findByRole('dialog', { name: '确认操作' });
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '违规内容',
});
expect(await screen.findByText('游戏《测试游戏》已下架')).toBeTruthy();
});
test('版本历史弹层展示版本条目与统计信息', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '版本历史' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('共 21 个版本,展示最近 20 个')).toBeTruthy();
expect(within(dialog).getByText('v3')).toBeTruthy();
expect(within(dialog).getByText('published')).toBeTruthy();
expect(within(dialog).getByText('2.0 KiB')).toBeTruthy();
expect(within(dialog).getByText('abcdef012345')).toBeTruthy();
expect(within(dialog).getByText('测试原因')).toBeTruthy();
expect(
within(dialog).getByText('https://game.example.com/game_1'),
).toBeTruthy();
});
test('接口失败显示错误文案', async () => {
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
new Error('游戏列表读取失败'),
);
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('游戏列表读取失败')).toBeTruthy();
});
test('401 交给 onUnauthorized 处理', async () => {
const onUnauthorized = vi.fn();
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
Object.assign(new Error('未授权'), { status: 401 }),
);
render(
<AdminGameManagementPage
token="admin-token"
onUnauthorized={onUnauthorized}
/>,
);
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
expect(screen.queryByRole('alert')).toBeNull();
});
test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), {
target: { value: ' 拼图 ' },
});
fireEvent.change(screen.getByPlaceholderText('作者 user ID'), {
target: { value: 'user_9' },
});
fireEvent.change(screen.getByRole('combobox', { name: '状态' }), {
target: { value: 'suspended' },
});
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
);
const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!;
expect(applied).toEqual({
limit: 50,
cursor: null,
keyword: '拼图',
owner: 'user_9',
status: 'suspended',
});
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3),
);
const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!;
expect(reset).toEqual({
limit: 50,
cursor: null,
keyword: '',
owner: '',
status: '',
});
expect(
(screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement)
.value,
).toBe('');
});
test('游标分页把上一页返回的 nextCursor 传给下一页', async () => {
vi.mocked(listAdminGameDistributionGames)
.mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' })
.mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null });
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
const nextButton = screen.getByRole('button', { name: '下一页' });
expect((nextButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(nextButton);
await screen.findByText('下架游戏');
expect(screen.queryByText('测试游戏')).toBeNull();
const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock
.calls[1]!;
expect(secondPage?.cursor).toBe('cursor-1');
expect(
(screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
test('已删除作品显示删除时间且不再提供下架与恢复', async () => {
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
games: [deletedGame],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const row = (await screen.findByText('已删除作品')).closest('tr')!;
expect(within(row).getByText('已删除')).toBeTruthy();
expect(within(row).getByText(/删除于/)).toBeTruthy();
expect(within(row).queryByRole('button', { name: '下架' })).toBeNull();
expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull();
expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy();
});