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 - 文档:后台游戏审核详情计划加取代标注,说明审核页只承载版本级动作
432 lines
14 KiB
TypeScript
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();
|
|
});
|