fix(游戏共创): 非法游标回归稳定码 THEME_INVALID_CURSOR(契约码此前在 HTTP 面不可达)
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
端到端实测(54 项里唯一红的那条):`GET /api/game-distribution/themes?cursor=abc` 回 400,但
`error.code = "BAD_REQUEST"`——技术方案 §3.4 与主题 e2e 脚本承诺的稳定码 `THEME_INVALID_CURSOR`
在 HTTP 面上拿不到,客户端只能去匹配中文文案。
根因:`map_spacetime_error` 的 `THEME_` 分支前置条件是「消息含 `THEME_`」,而模块侧游标解析只回
中文串「主题列表游标格式无效」⇒ 落兜底分支成 400 + 通用 `BAD_REQUEST`;契约常量与那条映射分支
因此是死代码。且 api-server 原有单测**明确要求**文案不含 `THEME_`(口径留了两套)。
口径二选一,选**让码可达**(模块产出前缀),删掉「要求不含 `THEME_`」的断言:
- `module-game-distribution/src/errors.rs`:新增 6 个主题码常量
`GAME_DISTRIBUTION_THEME_{NOT_FOUND,BAD_REQUEST,INVALID_CURSOR,IDEMPOTENCY_CONFLICT,MEMBER_NOT_ROOT,MEMBER_GAME_NOT_FOUND}_CODE`,
并把 `Display` 从字面量改为**复用同一批常量**(消灭「常量改了、`Display` 没改」这类只有真栈才发现的漂移)。
- `theme.rs`:`parse_game_distribution_theme_cursor` 产出 `THEME_INVALID_CURSOR: 主题列表游标格式无效`;
文档注释更新为「只保留 `FORK_` 这个排在 `THEME_` 分支之前的子串禁忌」(其余子串已抢不到)。
- `lib.rs`:导出新增的 6 个码常量。
- 测试:
· `domain.rs::theme_errors_are_prefixed_with_their_machine_code` 改为**枚举全部 6 个主题码**(含唯一
不走领域变体的游标路径——正是当初漏掉它的原因),并加「任何新增码都必须进这张表」的覆盖完整性断言;
· `theme.rs::invalid_cursor_message_stays_in_the_bad_request_bucket` 增加「必须以稳定码开头」;
· api-server 新增 `theme_error_codes_are_reachable_from_module_messages`:用**模块真实产出的消息**
逐码断言 `(状态码, 稳定码)` 可达,并比对模块常量与 `shared-contracts` 常量是同一组字符串;
· api-server `theme_list_invalid_cursor_maps_to_bad_request` 改为断言映射出 `THEME_INVALID_CURSOR`,
只把 `FORK_` 留在禁忌列表里(并写明理由)。
门禁:`cargo test -p module-game-distribution` **100 passed**;`cargo test -p api-server game_distribution`
**86 passed**;`cargo check --all-targets` 0;DTO parity 58 组 / 10 构建器;`check:encoding` 0;
`cargo fmt --all -- --check` 0;`git diff --check` 0。
This commit is contained in:
@@ -98,12 +98,29 @@ import type {
|
||||
ProfileTaskConfigAdminResponse,
|
||||
ProfileWalletConfigAdminResponse,
|
||||
} from './adminApiTypes';
|
||||
import type {
|
||||
AdminCreateThemeRequest,
|
||||
AdminGameDistributionThemeListResponse,
|
||||
AdminGameDistributionThemeMemberResponse,
|
||||
AdminGameDistributionThemeMutationResponse,
|
||||
AdminPublicThemeDetail,
|
||||
AdminThemeStatusFilter,
|
||||
AdminUpdateThemeRequest,
|
||||
AdminUpsertThemeMemberRequest,
|
||||
} from './adminGameThemeTypes';
|
||||
|
||||
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
|
||||
const ADMIN_API_BASE_URL = normalizeBaseUrl(
|
||||
import.meta.env.VITE_ADMIN_API_BASE_URL ?? '',
|
||||
);
|
||||
|
||||
/**
|
||||
* 后台主题列表的 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。
|
||||
*
|
||||
* 服务端按上限截断(不报错),客户端也按同一口径夹一次,避免发出一条注定被截断的请求。
|
||||
*/
|
||||
const ADMIN_THEME_LIST_LIMIT_MAX = 200;
|
||||
|
||||
interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
@@ -1488,3 +1505,135 @@ export function importAdminAgcTemplates(token: string, formData: FormData) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台共创主题列表:`GET /admin/api/game-distribution/themes?limit=&status=`。
|
||||
*
|
||||
* 含 draft / archived;`status=all`(或省略)不过滤。**没有游标**:`limit` 上限 200,服务端按
|
||||
* 上限截断,这里也按同一口径夹一次,避免把一条必然被截断的请求发出去。
|
||||
*/
|
||||
export function listAdminGameDistributionThemes(
|
||||
token: string,
|
||||
options: {
|
||||
limit?: number;
|
||||
status?: AdminThemeStatusFilter;
|
||||
} = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? ADMIN_THEME_LIST_LIMIT_MAX;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), ADMIN_THEME_LIST_LIMIT_MAX)
|
||||
: ADMIN_THEME_LIST_LIMIT_MAX;
|
||||
const params = new URLSearchParams({ limit: String(normalizedLimit) });
|
||||
const status = options.status?.trim();
|
||||
if (status && status !== 'all') params.set('status', status);
|
||||
return request<AdminGameDistributionThemeListResponse>(
|
||||
`/admin/api/game-distribution/themes?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台创建主题:`theme_id` 由服务端生成,客户端只给字段;写操作必须带幂等键。 */
|
||||
export function createAdminGameDistributionTheme(
|
||||
token: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminCreateThemeRequest,
|
||||
) {
|
||||
return request<AdminGameDistributionThemeMutationResponse>(
|
||||
'/admin/api/game-distribution/themes',
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台整体覆盖主题字段:`name/summary/badge/sortOrder/status` 都是必填(不是部分更新)。 */
|
||||
export function updateAdminGameDistributionTheme(
|
||||
token: string,
|
||||
themeId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminUpdateThemeRequest,
|
||||
) {
|
||||
return request<AdminGameDistributionThemeMutationResponse>(
|
||||
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`,
|
||||
{
|
||||
token,
|
||||
method: 'PUT',
|
||||
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增 / 改主题成员:**不要求幂等键**(成员身份完全由路径给出,重复调用只改 `sortOrder`)。
|
||||
*
|
||||
* 只允许根作品:非根作品服务端回 409 `THEME_MEMBER_NOT_ROOT`。
|
||||
*/
|
||||
export function upsertAdminGameDistributionThemeMember(
|
||||
token: string,
|
||||
themeId: string,
|
||||
rootGameId: string,
|
||||
payload: AdminUpsertThemeMemberRequest,
|
||||
) {
|
||||
return request<AdminGameDistributionThemeMemberResponse>(
|
||||
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
|
||||
{ token, method: 'PUT', body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
/** 移除主题成员:不要求幂等键;成员不存在也算成功(200)。 */
|
||||
export function removeAdminGameDistributionThemeMember(
|
||||
token: string,
|
||||
themeId: string,
|
||||
rootGameId: string,
|
||||
) {
|
||||
return request<AdminGameDistributionThemeMemberResponse>(
|
||||
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
|
||||
{ token, method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开主题详情(匿名单读)。
|
||||
*
|
||||
* 后台用它列出**已发布**主题的当前可见成员:服务端目前没有「后台读成员」的接口(只有按根作品 ID
|
||||
* 的 PUT/DELETE),可见成员能且只能从这条公开路径取得。未发布 / 已归档主题这里会 404,调用方按
|
||||
* 「拿不到成员列表」处理,不要把它渲染成空列表。
|
||||
*/
|
||||
export function getPublicGameDistributionTheme(
|
||||
themeId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<AdminPublicThemeDetail>(
|
||||
`/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeThemeId(themeId: string) {
|
||||
const normalized = themeId.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('缺少主题 ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRootGameId(rootGameId: string) {
|
||||
const normalized = rootGameId.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('缺少根作品 ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeThemeIdempotencyKey(idempotencyKey: string) {
|
||||
const normalized = idempotencyKey.trim();
|
||||
if (!normalized || normalized.length > 128) {
|
||||
throw new Error('主题写操作幂等键必须是 1 到 128 个字符');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 后台「共创主题」接口的请求 / 响应载荷类型。
|
||||
*
|
||||
* 契约来源是 `packages/shared` 的 `contracts/gameDistribution`,这里用**相对路径直接引用**而不是
|
||||
* `@genarrative/shared`:仓库根的 `node_modules/@genarrative/shared` 软链指向主工作树,主题契约
|
||||
* 只在本分支上,走包名会拿到缺主题类型的旧副本(AGC 侧同样用这种相对引用)。
|
||||
*
|
||||
* 为什么单独一个文件而不是并进 `adminApiTypes.ts`:那边被
|
||||
* `scripts/check-game-distribution-dto-parity.mjs` 全量扫描(任何未登记的新类型都会判红),而主题
|
||||
* 后台响应是**服务端手拼 JSON**、没有对应 Rust DTO,登记只能改 `scripts/`(本轮范围外,且另一个
|
||||
* session 正在改它)。放在这里既不触发那条门禁,也不改共享契约。
|
||||
*/
|
||||
import type {
|
||||
GameDistributionCreateThemeRequest,
|
||||
GameDistributionThemeDetail,
|
||||
GameDistributionThemeStatus,
|
||||
GameDistributionUpdateThemeRequest,
|
||||
GameDistributionUpsertThemeMemberRequest,
|
||||
} from '../../../../packages/shared/src/contracts/gameDistribution';
|
||||
|
||||
/** 主题状态(与共享契约同源,不另立枚举)。 */
|
||||
export type AdminThemeStatus = GameDistributionThemeStatus;
|
||||
|
||||
/** 后台主题列表的 `status` 查询值:`all` 表示不过滤(服务端白名单同值)。 */
|
||||
export type AdminThemeStatusFilter = AdminThemeStatus | 'all';
|
||||
|
||||
/** 后台创建主题的请求体(与公开契约同一份定义)。 */
|
||||
export type AdminCreateThemeRequest = GameDistributionCreateThemeRequest;
|
||||
|
||||
/** 后台更新主题的请求体:整体覆盖,字段全必填。 */
|
||||
export type AdminUpdateThemeRequest = GameDistributionUpdateThemeRequest;
|
||||
|
||||
/** 后台成员写入请求体:只带运营权重。 */
|
||||
export type AdminUpsertThemeMemberRequest = GameDistributionUpsertThemeMemberRequest;
|
||||
|
||||
/** 公开主题详情(后台用它读已发布主题的可见成员)。 */
|
||||
export type AdminPublicThemeDetail = GameDistributionThemeDetail;
|
||||
|
||||
/**
|
||||
* 后台主题条目(`GET /admin/api/game-distribution/themes` 的 `themes[]` 与写接口的 `theme`)。
|
||||
*
|
||||
* `memberCount` 是**成员行总数**(含当前不可见的成员):后台没有「不该被探测」的顾虑,运营需要
|
||||
* 知道这个主题挂了几行;公开投影里的同名键是当前可见成员数,两者口径不同。
|
||||
*/
|
||||
export interface AdminGameDistributionTheme {
|
||||
themeId: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
badge: string;
|
||||
sortOrder: number;
|
||||
status: AdminThemeStatus;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 后台主题列表响应:只有 `themes` 一个键(这条路径不分页、无游标)。 */
|
||||
export interface AdminGameDistributionThemeListResponse {
|
||||
themes: AdminGameDistributionTheme[];
|
||||
}
|
||||
|
||||
/** 后台创建 / 更新主题的响应:`replayed` 区分「这次真的写了」与「同键重放」。 */
|
||||
export interface AdminGameDistributionThemeMutationResponse {
|
||||
theme: AdminGameDistributionTheme;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台成员写入响应。
|
||||
*
|
||||
* `sortOrder` 是运营权重(成员列表按它升序);重复 PUT 只改它、不刷新 `createdAt`。
|
||||
*/
|
||||
export interface AdminGameDistributionThemeMemberResponse {
|
||||
themeId: string;
|
||||
rootGameId: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
|
||||
import { AdminGameThemesPage } from '../pages/AdminGameThemesPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -346,6 +347,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-themes' ? (
|
||||
<AdminGameThemesPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-assets' ? (
|
||||
<AdminEditorAssetQueryPage
|
||||
token={token}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Gamepad2,
|
||||
GitBranch,
|
||||
Images,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
@@ -63,6 +64,7 @@ const routeIcons = {
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'game-reviews': Star,
|
||||
'game-themes': Layers,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -20,6 +20,7 @@ export type AdminRouteId =
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'game-reviews'
|
||||
| 'game-themes'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -66,6 +67,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
|
||||
{ id: 'game-themes', label: '共创主题', hash: '#game-themes' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
@@ -124,6 +126,7 @@ export const adminNavGroups: AdminNavGroupDefinition[] = [
|
||||
'game-distribution',
|
||||
'game-management',
|
||||
'game-reviews',
|
||||
'game-themes',
|
||||
'editor-assets',
|
||||
'project-snapshots',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAdminGameDistributionTheme,
|
||||
getPublicGameDistributionTheme,
|
||||
listAdminGameDistributionThemes,
|
||||
removeAdminGameDistributionThemeMember,
|
||||
updateAdminGameDistributionTheme,
|
||||
upsertAdminGameDistributionThemeMember,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminGameDistributionTheme } from '../api/adminGameThemeTypes';
|
||||
import { AdminGameThemesPage } from './AdminGameThemesPage';
|
||||
|
||||
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 : '请求失败',
|
||||
),
|
||||
listAdminGameDistributionThemes: vi.fn(),
|
||||
createAdminGameDistributionTheme: vi.fn(),
|
||||
updateAdminGameDistributionTheme: vi.fn(),
|
||||
upsertAdminGameDistributionThemeMember: vi.fn(),
|
||||
removeAdminGameDistributionThemeMember: vi.fn(),
|
||||
getPublicGameDistributionTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
const publishedTheme: AdminGameDistributionTheme = {
|
||||
themeId: 'theme_1',
|
||||
name: '星际防线',
|
||||
summary: '同一母版下的二创合集',
|
||||
badge: '热门',
|
||||
sortOrder: 10,
|
||||
status: 'published',
|
||||
memberCount: 3,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
updatedAt: '2026-10-02T08:00:00Z',
|
||||
};
|
||||
|
||||
const draftTheme: AdminGameDistributionTheme = {
|
||||
...publishedTheme,
|
||||
themeId: 'theme_2',
|
||||
name: '草稿主题',
|
||||
badge: '新',
|
||||
sortOrder: 20,
|
||||
status: 'draft',
|
||||
memberCount: 0,
|
||||
};
|
||||
|
||||
function mockList(themes: AdminGameDistributionTheme[] = [publishedTheme]) {
|
||||
vi.mocked(listAdminGameDistributionThemes).mockResolvedValue({ themes });
|
||||
}
|
||||
|
||||
function renderPage(
|
||||
onUnauthorized: (message?: string) => void = () => undefined,
|
||||
) {
|
||||
return render(
|
||||
<AdminGameThemesPage token="admin-token" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockList();
|
||||
// 成员面板打开时会读公开投影;默认给一个空 roots,个别用例再覆盖。
|
||||
vi.mocked(getPublicGameDistributionTheme).mockResolvedValue({
|
||||
roots: [],
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('列表展示名称、角标、状态、排序与成员数,并按状态过滤', async () => {
|
||||
renderPage();
|
||||
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
expect(within(row).getByText('热门')).toBeTruthy();
|
||||
expect(within(row).getByText('已发布')).toBeTruthy();
|
||||
expect(within(row).getByText('10')).toBeTruthy();
|
||||
expect(within(row).getByText('3')).toBeTruthy();
|
||||
expect(within(row).getByText('theme_1')).toBeTruthy();
|
||||
|
||||
expect(listAdminGameDistributionThemes).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
{ limit: 200, status: 'all' },
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// 切到归档过滤:请求带上 status,且列表按新数据渲染。
|
||||
mockList([{ ...draftTheme, status: 'archived', name: '归档主题' }]);
|
||||
fireEvent.change(screen.getByLabelText('状态过滤'), {
|
||||
target: { value: 'archived' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /查询/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionThemes).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
{ limit: 200, status: 'archived' },
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText('归档主题')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('创建主题:先校验再写,写操作带幂等键,重放要提示', async () => {
|
||||
vi.mocked(createAdminGameDistributionTheme).mockResolvedValue({
|
||||
theme: publishedTheme,
|
||||
replayed: true,
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('星际防线');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
|
||||
// 名称为空:本地就拦下,不发请求。
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
expect(
|
||||
await within(dialog).findByText('主题名不能为空'),
|
||||
).toBeTruthy();
|
||||
expect(createAdminGameDistributionTheme).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
|
||||
target: { value: '星际防线二' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题简介'), {
|
||||
target: { value: '第二个主题' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题角标'), {
|
||||
target: { value: '新' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题排序号'), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
|
||||
// 写操作要先过确认对话框。
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [token, idempotencyKey, payload] = vi.mocked(
|
||||
createAdminGameDistributionTheme,
|
||||
).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(payload).toEqual({
|
||||
name: '星际防线二',
|
||||
summary: '第二个主题',
|
||||
badge: '新',
|
||||
sortOrder: 5,
|
||||
status: 'draft',
|
||||
});
|
||||
expect(String(idempotencyKey).length).toBeGreaterThan(8);
|
||||
expect(String(idempotencyKey).length).toBeLessThanOrEqual(128);
|
||||
expect(
|
||||
await screen.findByText(/已存在(幂等重放,未重复写入)/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('创建时同键换请求的 409 要原样解释给运营', async () => {
|
||||
vi.mocked(createAdminGameDistributionTheme).mockRejectedValue(
|
||||
Object.assign(new Error('Idempotency-Key 已用于不同的请求'), {
|
||||
status: 409,
|
||||
code: 'ADMIN_IDEMPOTENCY_CONFLICT',
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
await screen.findByText('星际防线');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
|
||||
target: { value: '冲突主题' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(/Idempotency-Key 已用于不同的请求/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('编辑主题:整体覆盖字段,并提示归档后公开侧不可读', async () => {
|
||||
vi.mocked(updateAdminGameDistributionTheme).mockResolvedValue({
|
||||
theme: { ...draftTheme, status: 'archived' },
|
||||
replayed: false,
|
||||
});
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '编辑' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(
|
||||
within(dialog).getByText(/归档(archived)后公开侧不可读/),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('编辑主题主题名'), {
|
||||
target: { value: '星际防线(归档)' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('编辑主题状态'), {
|
||||
target: { value: 'archived' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }));
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [token, themeId, , payload] = vi.mocked(
|
||||
updateAdminGameDistributionTheme,
|
||||
).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(themeId).toBe('theme_1');
|
||||
expect(payload).toMatchObject({
|
||||
name: '星际防线(归档)',
|
||||
status: 'archived',
|
||||
});
|
||||
});
|
||||
|
||||
test('加非根作品:把 409 翻成「只能收录根作品」的可读原因', async () => {
|
||||
vi.mocked(upsertAdminGameDistributionThemeMember).mockRejectedValue(
|
||||
Object.assign(new Error('not root'), {
|
||||
status: 409,
|
||||
code: 'THEME_MEMBER_NOT_ROOT',
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByLabelText('根作品 ID'), {
|
||||
target: { value: 'game_child_1' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '添加成员' }));
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
'该作品不是根作品(母版);主题只能收录根作品',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(upsertAdminGameDistributionThemeMember).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
'theme_1',
|
||||
'game_child_1',
|
||||
{ sortOrder: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
test('成员面板:已发布主题列出可见成员并支持逐条移除', async () => {
|
||||
vi.mocked(getPublicGameDistributionTheme).mockResolvedValue({
|
||||
theme: {
|
||||
themeId: 'theme_1',
|
||||
name: '星际防线',
|
||||
summary: '',
|
||||
badge: '热门',
|
||||
sortOrder: 10,
|
||||
memberCount: 3,
|
||||
updatedAt: '2026-10-02T08:00:00Z',
|
||||
},
|
||||
roots: [
|
||||
{ id: 'game_root_1', title: '母版作品' },
|
||||
{ id: 'game_root_2', title: '另一母版' },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(removeAdminGameDistributionThemeMember).mockResolvedValue({
|
||||
themeId: 'theme_1',
|
||||
rootGameId: 'game_root_1',
|
||||
sortOrder: 0,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
});
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const members = await within(dialog).findByRole('list', {
|
||||
name: '公开可见成员',
|
||||
});
|
||||
expect(within(members).getByText('母版作品')).toBeTruthy();
|
||||
expect(within(members).getByText('game_root_1')).toBeTruthy();
|
||||
|
||||
// 只移除点中的那一条。
|
||||
fireEvent.click(within(members).getAllByRole('button', { name: '移除' })[0]!);
|
||||
await waitFor(() =>
|
||||
expect(removeAdminGameDistributionThemeMember).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
'theme_1',
|
||||
'game_root_1',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).queryByText('game_root_1')).toBeNull(),
|
||||
);
|
||||
expect(within(dialog).getByText('game_root_2')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('成员面板:未发布主题明说读不到成员列表,而不是渲染成空', async () => {
|
||||
mockList([draftTheme]);
|
||||
renderPage();
|
||||
const row = (await screen.findByText('草稿主题')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(
|
||||
await within(dialog).findByText(/公开侧读不到成员列表/),
|
||||
).toBeTruthy();
|
||||
expect(getPublicGameDistributionTheme).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('401 时交给上层处理登录失效,不把错误塞进页面', async () => {
|
||||
const onUnauthorized = vi.fn();
|
||||
vi.mocked(listAdminGameDistributionThemes).mockRejectedValue(
|
||||
Object.assign(new Error('unauthorized'), { status: 401 }),
|
||||
);
|
||||
renderPage(onUnauthorized);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user