Files
Genarrative/scripts/check-game-distribution-theme-e2e.mjs
T
suzmii f9f27c2949
Project CI / AI game creator shell Rust crates (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m55s
Project CI / Backend tests (pull_request) Successful in 9m31s
Project CI / Frontend tests (pull_request) Successful in 3m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m21s
Project CI / Native shell tests (pull_request) Successful in 6m50s
Project CI / Repository checks (pull_request) Successful in 4m58s
test(游戏共创): 主题 e2e 跟进 rootsTruncated 与后台成员名单,修正过期断言
- scripts/check-game-distribution-theme-e2e.mjs:
  · 头部契约引用**全部改成符号检索**(函数名/常量名),不再写会随提交漂移的行号
  · 新增「公开详情顶层键集合恰为契约七个(含 rootsTruncated)」与「小主题下 rootsTruncated 是布尔且
    为 false」两条;同时把「memberCount === roots.length」明确标注为只在小主题成立的观察,不再当不变式
  · 新增第 10 组:后台成员名单(未套公开可见性过滤、draft 也能读)——51 个未公开根作品 + 1 个软删除成员
    共 52 行,逐条断言:limit=999 截断到 50 且 totalMembers 仍为 52(limit=1 时 members 只有 1 行、
    totalMembers 不变)、默认 20 游标翻页 3 页 52 行不重不漏且末页 nextCursor=null、成员行键集合恰为
    {rootGameId,title,sortOrder,createdAt,visible,visibility}、按 sortOrder 升序、未公开成员
    visible=false+visibility=unpublished、软删除成员 visible=false+visibility=deleted(落在末页)、
    非法游标 400 THEME_INVALID_CURSOR、主题不存在 404、draft 主题也能读到成员、未登录 401 / 普通作者 403
  · 上一轮标红的「?cursor=abc → 稳定错误码 THEME_INVALID_CURSOR」断言**本轮转绿**(模块侧已把
    GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE 前缀拼进游标错误文案,映射分支可达)
  · 实测:71 项 71 PASS / 0 FAIL / 0 SKIP
- scripts/check-game-distribution-collection-e2e.mjs:修正随主题功能上线的**过期断言**——
  匿名作品详情现在比公开目录条目多一个 `themes` 键(详情侧增量,目录侧保持精简)。断言改为
  「目录条目是详情子集:共有键取值全一致 + 详情只多 themes 一处」,不再要求两边键数相等。
  实测:46 项 46 PASS / 0 FAIL / 0 SKIP
2026-10-06 04:35:06 +08:00

1513 lines
51 KiB
JavaScript
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.
// 游戏分发「共创主题」链路真实行为验收(公开读 + 后台写 + 作品详情 themes 增量)。
//
// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;不需要浏览器。
//
// 用法:
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
// node scripts/check-game-distribution-theme-e2e.mjs
// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口)
//
// 契约来源(逐条读实现确认;**一律用符号检索定位,不写行号**——行号会随提交漂移):
// [1] 公开主题路由(匿名 + no-store):`api-server/src/modules/game_distribution.rs` 里
// `public_games` 路由族的 `themes` / `themes/{theme_id}` 两条(`themes` 前缀可定位)
// [2] 公开列表分页与响应形状:`list_public_themes` + `public_themes_payload` + `themes_page_limit`
// [3] 公开详情:`get_public_theme` + `public_theme_detail_payload`
// (未发布/不存在都回「主题不存在」→ 404;已发布空成员是 200 + 空 roots)
// + `module-game-distribution/src/theme.rs` 的 `game_distribution_theme_public_visible`
// [4] 可见性与 memberCount 判定:`game_distribution_theme_visible_members`
// (未删 + 已公开 + 有当前公开版本)+ `game_distribution_theme_roots_truncated`
// [5] 作品详情 `themes` 增量「先取根再反查」:`game_distribution_themes_for_game`
// [6] 后台写路由(admin 鉴权):`admin_create_theme` / `admin_update_theme` /
// `admin_set_theme_member` / `admin_delete_theme_member` / `admin_list_themes` /
// `admin_list_theme_members`(成员名单不套公开可见性过滤)
// [7] 错误码映射:`map_spacetime_error` 里的 `GAME_DISTRIBUTION_THEME_*` 分支前缀判定
// [8] 分页极限与游标:`game_distribution_theme_page_limit`(默认 20 / 上限 50 / 0 取默认 / 超界截断)、
// `parse_game_distribution_theme_cursor`、`page_admin_theme_members`
// (成员名单缺省 20 / 上限 50,按 `sort_order` 升序 + `member_id` 兜底,游标 `{sort_order}:{memberId}`)
//
// 复用/照抄的 helper(注释标了出处;抽公共模块会改动其它已提交脚本,故照抄):
// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs 的
// `const devStack = JSON.parse(...)` 那一段
// - check/note/api/brief/register/gameMetadata/uploadCover/createGame/ownerGame/publicDetail:
// lineage 脚本同名 helper
// - 完整发布链路(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本的 `publishToPublic`
// - 改编声明(fork 字段)+ 公开版本 id 取法:lineage 脚本里取 `versions.find(status==='published')` 那一段
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import JSZip from 'jszip';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
const GATE_KEY = 'game-distribution:publish';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
'game-distribution:publish 写入口并把作品推到公开(主题成员只呈现公开作品)。',
);
process.exit(2);
}
const devStack = JSON.parse(
readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
);
const API = (
process.env.E2E_API_BASE ??
devStack.services?.['api-server']?.url ??
''
).replace(/\/+$/u, '');
if (!API) {
console.error(
'无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' +
'或用 E2E_API_BASE 显式指定。',
);
process.exit(2);
}
let checks = 0;
let failures = 0;
const skipped = 0;
function check(name, ok, detail = '') {
checks += 1;
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
function note(message) {
console.log(`NOTE ${message}`);
}
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
signal: AbortSignal.timeout(120_000),
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
text,
json,
data: json?.data,
error: json?.error,
cacheControl: response.headers.get('cache-control') ?? '',
};
}
function brief(body) {
const code = body?.error?.code ?? body?.json?.error?.code ?? '';
return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 200)}`;
}
function errorCode(body) {
return body?.error?.code ?? body?.json?.error?.code ?? '';
}
function errorText(body) {
return String(
body?.error?.details?.message ??
body?.error?.message ??
body?.json?.error?.message ??
'',
);
}
async function register(prefix) {
const phone = `${prefix}${String(Date.now()).slice(-8)}`;
const response = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
return { phone, response, token: response.data?.token };
}
function gameMetadata({ title, coverAssetId }) {
return {
title,
summary: '共创主题验收临时作品',
description: '',
category: '休闲',
tags: ['e2e'],
coverAssetId,
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function uploadCover(token, id) {
const fileName = `theme-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'theme', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) {
throw new Error(`直传对象存储失败 ${put.status}`);
}
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-theme',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
async function createGame({ token, metadata, idemKey, fork }) {
return api('/api/game-distribution/games', {
method: 'POST',
token,
headers: { 'Idempotency-Key': idemKey },
body: fork ? { ...metadata, fork } : metadata,
});
}
async function ownerGame(token, gameId) {
const response = await api(`/api/game-distribution/my-games/${gameId}`, {
token,
});
return { response, game: response.data?.game ?? null };
}
function publicDetail(gameId, token) {
return api(`/api/game-distribution/games/${gameId}`, { token });
}
async function buildReleaseZip(marker) {
const zip = new JSZip();
zip.file(
'index.html',
`<!doctype html><html><head><meta charset="utf-8"><title>${marker}</title></head><body><h1>${marker}</h1></body></html>`,
);
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
return {
bytes,
fileCount: 1,
sha256: createHash('sha256').update(bytes).digest('hex'),
};
}
/// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。
async function publishToPublic({
token,
admin,
gameId,
gameRevision,
metadata,
stamp,
tag,
label,
quiet = false,
}) {
const zip = await buildReleaseZip(`theme-${tag}-${stamp}`);
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': `theme-version-${tag}-${stamp}` },
body: {
packageSha256: zip.sha256,
packageBytes: zip.bytes.length,
packageFileCount: zip.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
const versionId = created.data?.versionId;
if (!quiet) {
check(
`${label} 版本创建成功`,
created.status === 200 && Boolean(versionId),
`status=${created.status} ${created.text.slice(0, 140)}`,
);
}
if (!versionId) return { versionId: null };
const upload = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token,
headers: {
'Idempotency-Key': `theme-upload-${tag}-${stamp}`,
'Content-Type': 'application/zip',
},
binary: zip.bytes,
},
);
if (!quiet) {
check(
`${label} 发行包上传成功`,
upload.status === 200,
`status=${upload.status}`,
);
}
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': `theme-submit-${tag}-${stamp}` },
body: { expectedPublicationRevision: gameRevision },
},
);
if (!quiet) {
check(
`${label} 送审成功(202)`,
submitted.status === 202,
`status=${submitted.status} ${submitted.text.slice(0, 140)}`,
);
}
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token,
});
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `theme-approve-${tag}-${stamp}` },
body: {
decision: 'approve',
expectedPublicationRevision:
readback.data?.version?.publicationRevision ?? gameRevision,
},
},
);
if (!quiet) {
check(
`${label} 管理员审核通过并公开`,
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 140)}`,
);
}
return { versionId };
}
// ---------- 主题专用 helper ----------
function adminCreateTheme({ admin, body, key }) {
return api('/admin/api/game-distribution/themes', {
method: 'POST',
token: admin,
headers: key === undefined ? {} : { 'Idempotency-Key': key },
body,
});
}
function adminUpdateTheme({ admin, themeId, body, key }) {
return api(`/admin/api/game-distribution/themes/${themeId}`, {
method: 'PUT',
token: admin,
headers: key === undefined ? {} : { 'Idempotency-Key': key },
body,
});
}
function adminPutMember({ admin, themeId, rootGameId, sortOrder = 0 }) {
return api(
`/admin/api/game-distribution/themes/${themeId}/members/${rootGameId}`,
{
method: 'PUT',
token: admin,
body: { sortOrder },
},
);
}
function adminDeleteMember({ admin, themeId, rootGameId }) {
return api(
`/admin/api/game-distribution/themes/${themeId}/members/${rootGameId}`,
{
method: 'DELETE',
token: admin,
},
);
}
function adminListThemes({ admin, limit, status } = {}) {
const query = new URLSearchParams();
if (limit !== undefined) query.set('limit', String(limit));
if (status !== undefined) query.set('status', status);
const suffix = query.toString() ? `?${query.toString()}` : '';
return api(`/admin/api/game-distribution/themes${suffix}`, { token: admin });
}
function publicThemes({ limit, cursor } = {}) {
const query = new URLSearchParams();
if (limit !== undefined) query.set('limit', String(limit));
if (cursor !== undefined) query.set('cursor', cursor);
const suffix = query.toString() ? `?${query.toString()}` : '';
return api(`/api/game-distribution/themes${suffix}`);
}
function publicThemeDetail(themeId) {
return api(`/api/game-distribution/themes/${themeId}`);
}
function adminThemeMembers({ admin, themeId, limit, cursor } = {}) {
const query = new URLSearchParams();
if (limit !== undefined) query.set('limit', String(limit));
if (cursor !== undefined) query.set('cursor', cursor);
const suffix = query.toString() ? `?${query.toString()}` : '';
return api(
`/admin/api/game-distribution/themes/${themeId}/members${suffix}`,
{ token: admin },
);
}
function softDeleteGame({ token, gameId, revision, key }) {
return api(
`/api/game-distribution/my-games/${gameId}?expectedPublicationRevision=${revision}`,
{ method: 'DELETE', token, headers: { 'Idempotency-Key': key } },
);
}
function themeIdsOf(body) {
return (body?.data?.themes ?? []).map((theme) => theme.themeId);
}
// ---------- 主流程 ----------
async function main() {
console.log(`[theme-e2e] api-server=${API} database=${devStack.database}`);
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
const gate = await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: GATE_KEY,
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 共创主题链路',
},
});
check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
const stamp = Date.now();
const suffix = String(stamp).slice(-6);
const author = await register('132');
const stranger = await register('133');
check(
'作者注册拿到 token',
author.response.status === 200 && Boolean(author.token),
`status=${author.response.status}`,
);
check(
'另一个普通账号(非 admin)注册拿到 token',
stranger.response.status === 200 && Boolean(stranger.token),
`status=${stranger.response.status}`,
);
if (!author.token || !stranger.token) process.exit(1);
// ---------- fixture:根作品 R(公开)+ 衍生作品 C(公开)+ 第二个根 R2 ----------
const rootMeta = gameMetadata({
title: `主题根作品 ${suffix}`,
coverAssetId: await uploadCover(author.token, `${stamp}-root`),
});
const rootCreated = await createGame({
token: author.token,
metadata: rootMeta,
idemKey: `theme-root-${stamp}`,
});
const rootId = rootCreated.data?.id;
check(
'根作品 R 创建成功',
rootCreated.status === 200 && Boolean(rootId),
`status=${rootCreated.status} id=${rootId ?? ''}`,
);
if (!rootId) process.exit(1);
await publishToPublic({
token: author.token,
admin,
gameId: rootId,
gameRevision: rootCreated.data?.publicationRevision ?? 0,
metadata: rootMeta,
stamp,
tag: 'root',
label: '根作品 R',
quiet: true,
});
check(
'根作品 R 已公开可读',
(await publicDetail(rootId)).data?.status === 'published',
'status=published',
);
// 派生作品 C:需要 R 已公开 + 授权非禁止 + R 的当前公开版本 ID
const promote = await api(
`/api/game-distribution/games/${rootId}/fork-authorization`,
{
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': `theme-promote-${stamp}` },
body: {
expectedForkAuthorization: 'forbidden',
forkAuthorization: 'nonCommercial',
},
},
);
check(
'根作品 R 共创授权提升为 nonCommercial(衍生作品前提)',
promote.status === 200,
brief(promote),
);
const rootVersions =
(await ownerGame(author.token, rootId)).game?.versions ?? [];
const rootPublicVersionId =
rootVersions.find((version) => version.status === 'published')?.versionId ??
'';
const childMeta = gameMetadata({
title: `主题衍生作品 ${suffix}`,
coverAssetId: await uploadCover(author.token, `${stamp}-child`),
});
const childCreated = await createGame({
token: author.token,
metadata: childMeta,
idemKey: `theme-child-${stamp}`,
fork: { parentGameId: rootId, parentVersionId: rootPublicVersionId },
});
const childId = childCreated.data?.id;
check(
'衍生作品 C(带改编声明)创建成功',
childCreated.status === 200 && Boolean(childId),
`status=${childCreated.status} id=${childId ?? ''} parentVersion=${rootPublicVersionId}`,
);
if (!childId) process.exit(1);
await publishToPublic({
token: author.token,
admin,
gameId: childId,
gameRevision: childCreated.data?.publicationRevision ?? 0,
metadata: childMeta,
stamp,
tag: 'child',
label: '衍生作品 C',
quiet: true,
});
check(
'衍生作品 C 已公开可读',
(await publicDetail(childId)).data?.status === 'published',
'status=published',
);
const secondRootMeta = gameMetadata({
title: `主题第二个根 ${suffix}`,
coverAssetId: await uploadCover(author.token, `${stamp}-root2`),
});
const secondRootCreated = await createGame({
token: author.token,
metadata: secondRootMeta,
idemKey: `theme-root2-${stamp}`,
});
const secondRootId = secondRootCreated.data?.id;
if (!secondRootId) {
check('第二个根作品 R2 创建成功', false, brief(secondRootCreated));
process.exit(1);
}
await publishToPublic({
token: author.token,
admin,
gameId: secondRootId,
gameRevision: secondRootCreated.data?.publicationRevision ?? 0,
metadata: secondRootMeta,
stamp,
tag: 'root2',
label: '根作品 R2',
quiet: true,
});
// ---------- 1:建主题 → 加根成员 → 公开读 ----------
const themeT1 = await adminCreateTheme({
admin,
key: `theme-t1-${stamp}`,
body: {
name: `主题 T1 ${suffix}`,
summary: '主题 T1 简介',
badge: 'T1',
sortOrder: 1,
status: 'published',
},
});
const t1Id = themeT1.data?.theme?.themeId;
check(
'1) 后台创建已发布主题 T1 成功',
themeT1.status === 200 &&
Boolean(t1Id) &&
themeT1.data?.theme?.status === 'published' &&
themeT1.data?.replayed === false,
brief(themeT1),
);
if (!t1Id) process.exit(1);
const memberPut = await adminPutMember({
admin,
themeId: t1Id,
rootGameId: rootId,
sortOrder: 5,
});
check(
'1) 加根成员 R 成功(成员写入不要求幂等键)',
memberPut.status === 200 &&
memberPut.data?.themeId === t1Id &&
memberPut.data?.rootGameId === rootId &&
memberPut.data?.sortOrder === 5,
brief(memberPut),
);
const listWithT1 = await publicThemes({ limit: 50 });
check(
'1) 公开列表只含已发布主题且含 T1、memberCount=1',
listWithT1.status === 200 &&
themeIdsOf(listWithT1).includes(t1Id) &&
(listWithT1.data?.themes ?? []).every(
(theme) => theme.status === undefined,
) &&
(listWithT1.data?.themes ?? []).find((theme) => theme.themeId === t1Id)
?.memberCount === 1,
`themes=${themeIdsOf(listWithT1).length} t1MemberCount=${
(listWithT1.data?.themes ?? []).find((theme) => theme.themeId === t1Id)
?.memberCount ?? 'n/a'
}`,
);
const detailT1 = await publicThemeDetail(t1Id);
const catalogEntry = (
await api('/api/game-distribution/games')
).data?.games?.find((game) => game.id === rootId);
check(
'1) 公开详情 roots 逐条是公开作品摘要且 memberCount 与可见成员数一致',
detailT1.status === 200 &&
detailT1.data?.roots?.length === 1 &&
detailT1.data?.roots?.[0]?.id === rootId &&
detailT1.data?.memberCount === detailT1.data?.roots?.length &&
JSON.stringify(Object.keys(detailT1.data.roots[0]).sort()) ===
JSON.stringify(Object.keys(catalogEntry ?? {}).sort()),
`roots=${detailT1.data?.roots?.length} memberCount=${detailT1.data?.memberCount} rootKeys=${Object.keys(detailT1.data?.roots?.[0] ?? {}).length} catalogKeys=${Object.keys(catalogEntry ?? {}).length}`,
);
check(
'1) 公开详情顶层键集合恰为契约的七个(含 rootsTruncated)',
JSON.stringify(Object.keys(detailT1.data ?? {}).sort()) ===
JSON.stringify(
[
'badge',
'memberCount',
'name',
'roots',
'rootsTruncated',
'summary',
'themeId',
].sort(),
),
`keys=${Object.keys(detailT1.data ?? {}).join(',')}`,
);
check(
'1) 小主题下 rootsTruncated 是布尔且为 false(memberCount === roots.length 只在这种小主题下成立)',
typeof detailT1.data?.rootsTruncated === 'boolean' &&
detailT1.data?.rootsTruncated === false &&
detailT1.data?.memberCount === detailT1.data?.roots?.length,
`rootsTruncated=${JSON.stringify(detailT1.data?.rootsTruncated)} memberCount=${detailT1.data?.memberCount} roots=${detailT1.data?.roots?.length}`,
);
// ---------- 2:只允许根 ----------
const notRoot = await adminPutMember({
admin,
themeId: t1Id,
rootGameId: childId,
});
check(
'2) 第 1 代子作品当成员 → 409 THEME_MEMBER_NOT_ROOT',
notRoot.status === 409 && errorCode(notRoot) === 'THEME_MEMBER_NOT_ROOT',
brief(notRoot),
);
const memberGameMissing = await adminPutMember({
admin,
themeId: t1Id,
rootGameId: `game_${'0'.repeat(32)}`,
});
check(
'2) 不存在的 gameId 当成员 → 404 THEME_MEMBER_GAME_NOT_FOUND',
memberGameMissing.status === 404 &&
errorCode(memberGameMissing) === 'THEME_MEMBER_GAME_NOT_FOUND',
brief(memberGameMissing),
);
// ---------- 3:跨主题多归属 ----------
const themeT2 = await adminCreateTheme({
admin,
key: `theme-t2-${stamp}`,
body: {
name: `主题 T2 ${suffix}`,
summary: '主题 T2 简介',
badge: 'T2',
sortOrder: 2,
status: 'published',
},
});
const t2Id = themeT2.data?.theme?.themeId;
check('3) 创建第二个已发布主题 T2 成功', Boolean(t2Id), brief(themeT2));
if (!t2Id) process.exit(1);
await adminPutMember({ admin, themeId: t2Id, rootGameId: rootId });
const detailT2 = await publicThemeDetail(t2Id);
check(
'3) 同一个根加入两个主题 → 两条主题都能读到它',
detailT2.data?.roots?.some((root) => root.id === rootId) &&
detailT1.data?.roots?.some((root) => root.id === rootId),
`t1Roots=${detailT1.data?.roots?.length} t2Roots=${detailT2.data?.roots?.length}`,
);
// ---------- 4:作品详情 themes 口径(含衍生作品) ----------
const rootDetailAnonymous = await publicDetail(rootId);
const rootDetailAuthed = await publicDetail(rootId, author.token);
const childDetailAnonymous = await publicDetail(childId);
const rootThemes = rootDetailAnonymous.data?.themes ?? [];
const childThemes = childDetailAnonymous.data?.themes ?? [];
check(
'4) 根作品详情的 themes 数组长度为 2(匿名与登录都发)',
Array.isArray(rootDetailAnonymous.data?.themes) &&
Array.isArray(rootDetailAuthed.data?.themes) &&
rootThemes.length === 2 &&
rootThemes
.map((theme) => theme.themeId)
.sort()
.join(',') === [t1Id, t2Id].sort().join(','),
`anonymous=${JSON.stringify(rootThemes.map((theme) => theme.themeId))} authed=${JSON.stringify((rootDetailAuthed.data?.themes ?? []).map((theme) => theme.themeId))}`,
);
check(
'4) 衍生作品(第 1 代)也返回其根所属主题(口径:先取根再反查)',
childThemes.length === 2 &&
childThemes
.map((theme) => theme.themeId)
.sort()
.join(',') === [t1Id, t2Id].sort().join(','),
`childThemes=${JSON.stringify(childThemes.map((theme) => theme.themeId))}`,
);
check(
'4) themes 条目的形状是 {themeId,name,badge}',
rootThemes.length > 0 &&
JSON.stringify(Object.keys(rootThemes[0]).sort()) ===
JSON.stringify(['badge', 'name', 'themeId']),
`keys=${Object.keys(rootThemes[0] ?? {}).join(',')}`,
);
check(
'4) 没有所属主题的作品返回空数组(不是缺键)',
(await publicDetail(secondRootId)).data?.themes?.length === 0,
`secondRootThemes=${JSON.stringify((await publicDetail(secondRootId)).data?.themes ?? null)}`,
);
// ---------- 5:可见性 ----------
const draftTheme = await adminCreateTheme({
admin,
key: `theme-draft-${stamp}`,
body: {
name: `主题草稿 ${suffix}`,
summary: '草稿主题',
badge: 'D',
sortOrder: 9,
status: 'draft',
},
});
const draftId = draftTheme.data?.theme?.themeId;
check('5) 创建草稿主题成功', Boolean(draftId), brief(draftTheme));
await adminPutMember({ admin, themeId: draftId, rootGameId: rootId });
const draftPublic = await publicThemeDetail(draftId);
check(
'5) 草稿主题公开详情 → 404(不返回空壳)',
draftPublic.status === 404 && errorText(draftPublic).includes('主题不存在'),
brief(draftPublic),
);
const adminListWithDraft = await adminListThemes({ admin, status: 'draft' });
check(
'5) 后台列表按 draft 过滤仍能看到该草稿主题(公开侧看不见、后台可见)',
adminListWithDraft.status === 200 &&
themeIdsOf({ data: adminListWithDraft.data }).includes(draftId),
`draftThemes=${themeIdsOf({ data: adminListWithDraft.data }).length}`,
);
const archived = await adminUpdateTheme({
admin,
themeId: t1Id,
key: `theme-archive-${stamp}`,
body: {
name: `主题 T1 ${suffix}`,
summary: '主题 T1 简介',
badge: 'T1',
sortOrder: 1,
status: 'archived',
},
});
check(
'5) 主题改为 archived 成功',
archived.status === 200 && archived.data?.theme?.status === 'archived',
brief(archived),
);
const archivedPublic = await publicThemeDetail(t1Id);
check(
'5) archived 主题公开详情 → 404;且其所属作品的 themes 也不含它',
archivedPublic.status === 404 &&
!((await publicDetail(rootId)).data?.themes ?? []).some(
(theme) => theme.themeId === t1Id,
),
brief(archivedPublic),
);
const republished = await adminUpdateTheme({
admin,
themeId: t1Id,
key: `theme-republish-${stamp}`,
body: {
name: `主题 T1 ${suffix}`,
summary: '主题 T1 简介',
badge: 'T1',
sortOrder: 1,
status: 'published',
},
});
check(
'5) 主题改回 published 后公开详情恢复 200',
republished.status === 200 &&
(await publicThemeDetail(t1Id)).status === 200,
brief(republished),
);
// 已发布但成员全不可见 → 200 + 空 roots;成员下架 → 消失,重新公开 → 自动回来
const emptyTheme = await adminCreateTheme({
admin,
key: `theme-empty-${stamp}`,
body: {
name: `主题空成员 ${suffix}`,
summary: '成员不可见的主题',
badge: 'E',
sortOrder: 3,
status: 'published',
},
});
const emptyThemeId = emptyTheme.data?.theme?.themeId;
await adminPutMember({
admin,
themeId: emptyThemeId,
rootGameId: secondRootId,
});
const beforeUnpublish = await publicThemeDetail(emptyThemeId);
check(
'5) 成员均可见时 memberCount=1',
beforeUnpublish.data?.memberCount === 1 &&
beforeUnpublish.data?.roots?.[0]?.id === secondRootId,
`memberCount=${beforeUnpublish.data?.memberCount}`,
);
const secondRootNow = await ownerGame(author.token, secondRootId);
const unpublishSecondRoot = await api(
`/api/game-distribution/games/${secondRootId}/unpublish`,
{
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `theme-unpublish-root2-${stamp}` },
body: {
expectedPublicationRevision:
secondRootNow.game?.publicationRevision ?? 0,
},
},
);
check(
'5) 成员作品 R2 下架成功',
unpublishSecondRoot.status === 200,
brief(unpublishSecondRoot),
);
const emptyAfterUnpublish = await publicThemeDetail(emptyThemeId);
check(
'5) 成员下架后:已发布主题仍是 200 + 空 roots + memberCount=0(不返回空壳 404)',
emptyAfterUnpublish.status === 200 &&
emptyAfterUnpublish.data?.roots?.length === 0 &&
emptyAfterUnpublish.data?.memberCount === 0,
`status=${emptyAfterUnpublish.status} roots=${emptyAfterUnpublish.data?.roots?.length} memberCount=${emptyAfterUnpublish.data?.memberCount}`,
);
await publishToPublic({
token: author.token,
admin,
gameId: secondRootId,
gameRevision:
(await ownerGame(author.token, secondRootId)).game?.publicationRevision ??
0,
metadata: secondRootMeta,
stamp,
tag: 'root2-v2',
label: '成员 R2 重新公开',
quiet: true,
});
const restored = await publicThemeDetail(emptyThemeId);
check(
'5) 成员重新公开后该根自动回到 roots(行未删除,只读侧过滤)且仍只有一行',
restored.status === 200 &&
restored.data?.roots?.length === 1 &&
restored.data?.roots?.[0]?.id === secondRootId &&
restored.data?.memberCount === 1,
`roots=${restored.data?.roots?.length} memberCount=${restored.data?.memberCount}`,
);
// ---------- 6:分页 ----------
const paginationThemeIds = [];
for (let index = 0; index < 51; index += 1) {
const created = await adminCreateTheme({
admin,
key: `theme-pag-${index}-${stamp}`,
body: {
name: `主题分页 ${suffix}-${index}`,
summary: '分页用例',
badge: 'P',
sortOrder: index,
status: 'published',
},
});
if (created.status !== 200) {
check(
'6) 分页用例主题创建成功',
false,
`index=${index} ${brief(created)}`,
);
}
paginationThemeIds.push(created.data?.theme?.themeId);
}
check(
'6) 51 个已发布主题创建完成(用于默认页大小 / 上限截断 / 游标翻页)',
paginationThemeIds.every(Boolean),
`created=${paginationThemeIds.filter(Boolean).length}`,
);
const defaultPage = await publicThemes();
check(
'6) 不给 limit → 默认 20 条且给出 nextCursor',
defaultPage.status === 200 &&
defaultPage.data?.themes?.length === 20 &&
typeof defaultPage.data?.nextCursor === 'string' &&
defaultPage.data.nextCursor.length > 0,
`size=${defaultPage.data?.themes?.length} nextCursor=${JSON.stringify(defaultPage.data?.nextCursor ?? null).slice(0, 40)}`,
);
const hugeLimit = await publicThemes({ limit: 999 });
check(
'6) limit=999 → 截断到上限 50(既不是错误,也不是全量)',
hugeLimit.status === 200 && hugeLimit.data?.themes?.length === 50,
`size=${hugeLimit.data?.themes?.length}`,
);
const collectedIds = [];
let cursor = undefined;
let pages = 0;
let lastCursor = 'unset';
for (;;) {
const page = await publicThemes({
limit: 20,
...(cursor ? { cursor } : {}),
});
if (page.status !== 200) {
check('6) 游标翻页每一页都 200', false, brief(page));
break;
}
pages += 1;
collectedIds.push(...themeIdsOf(page));
lastCursor = page.data?.nextCursor ?? null;
if (!lastCursor) break;
cursor = lastCursor;
if (pages > 20) {
check('6) 游标翻页在合理页数内结束', false, `pages=${pages}`);
break;
}
}
const uniqueIds = new Set(collectedIds);
check(
'6) 游标翻页不重复(所有 themeId 唯一)',
uniqueIds.size === collectedIds.length,
`pages=${pages} total=${collectedIds.length} unique=${uniqueIds.size}`,
);
check(
'6) 游标翻页不漏(本轮的 51 个分页主题 + T1/T2/空成员主题都能翻到)',
paginationThemeIds.every((id) => uniqueIds.has(id)) &&
uniqueIds.has(t1Id) &&
uniqueIds.has(t2Id) &&
uniqueIds.has(emptyThemeId),
`missing=${[...paginationThemeIds, t1Id, t2Id, emptyThemeId].filter((id) => id && !uniqueIds.has(id)).length}`,
);
check(
'6) 末页 nextCursor 为 null',
lastCursor === null,
`lastCursor=${JSON.stringify(lastCursor)}`,
);
const badCursor = await publicThemes({ cursor: 'abc' });
check(
'6) ?cursor=abc → 400(状态码符合契约)',
badCursor.status === 400,
brief(badCursor),
);
check(
'6) ?cursor=abc → 稳定错误码 THEME_INVALID_CURSOR(此前红断言,本轮应转绿)',
errorCode(badCursor) === 'THEME_INVALID_CURSOR',
brief(badCursor),
);
note(
'该断言是上一轮标红的产品缺口回归网:模块侧现在把常量前缀拼进游标错误文案' +
'(module-game-distribution/src/theme.rs 的 `GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE` + 「: 主题列表游标格式无效」),' +
'api-server 的 `map_spacetime_error` 前缀分支因此可达,客户端拿到的 error.code 从 BAD_REQUEST 变成 THEME_INVALID_CURSOR(状态码仍是 400)。',
);
// ---------- 7:后台权限 ----------
const anonymousAdminCalls = [
['GET 列表', () => adminListThemes({})],
[
'POST 创建',
() =>
api('/admin/api/game-distribution/themes', {
method: 'POST',
headers: { 'Idempotency-Key': `theme-anon-${stamp}` },
body: { name: `匿名主题 ${suffix}`, status: 'draft' },
}),
],
[
'PUT 更新',
() =>
api(`/admin/api/game-distribution/themes/${t1Id}`, {
method: 'PUT',
headers: { 'Idempotency-Key': `theme-anon-upd-${stamp}` },
body: {
name: `匿名改 ${suffix}`,
summary: '',
badge: '',
sortOrder: 0,
status: 'draft',
},
}),
],
[
'PUT 成员',
() =>
api(`/admin/api/game-distribution/themes/${t1Id}/members/${rootId}`, {
method: 'PUT',
body: { sortOrder: 0 },
}),
],
[
'DELETE 成员',
() =>
api(`/admin/api/game-distribution/themes/${t1Id}/members/${rootId}`, {
method: 'DELETE',
}),
],
];
for (const [label, call] of anonymousAdminCalls) {
const response = await call();
check(
`7) 未登录后台 ${label} → 401`,
response.status === 401,
brief(response),
);
}
const strangerAdmin = await api('/admin/api/game-distribution/themes', {
token: stranger.token,
});
check(
'7) 普通作者 token 访问后台主题列表 → 403',
strangerAdmin.status === 403,
brief(strangerAdmin),
);
const memberPutAgain = await adminPutMember({
admin,
themeId: t1Id,
rootGameId: rootId,
sortOrder: 7,
});
const detailAfterRepeat = await publicThemeDetail(t1Id);
check(
'7) 重复 PUT 同一成员只留一行(roots 仍 1 条)且 sortOrder 被更新',
memberPutAgain.status === 200 &&
memberPutAgain.data?.sortOrder === 7 &&
memberPutAgain.data?.createdAt === memberPut.data?.createdAt &&
detailAfterRepeat.data?.roots?.length === 1 &&
detailAfterRepeat.data?.memberCount === 1,
`createdAt(第一次)=${memberPut.data?.createdAt} createdAt(复传)=${memberPutAgain.data?.createdAt} roots=${detailAfterRepeat.data?.roots?.length}`,
);
const deleteExisting = await adminDeleteMember({
admin,
themeId: t2Id,
rootGameId: rootId,
});
const deleteExistingAgain = await adminDeleteMember({
admin,
themeId: t2Id,
rootGameId: rootId,
});
const deleteNeverAdded = await adminDeleteMember({
admin,
themeId: t2Id,
rootGameId: secondRootId,
});
check(
'7) DELETE 成员幂等:已删再删 data 相同;删不存在的成员同样 200 且同形状',
deleteExisting.status === 200 &&
JSON.stringify(deleteExisting.data) ===
JSON.stringify(deleteExistingAgain.data) &&
deleteNeverAdded.status === 200 &&
JSON.stringify(Object.keys(deleteNeverAdded.data ?? {}).sort()) ===
JSON.stringify(['rootGameId', 'themeId']),
`first=${JSON.stringify(deleteExisting.data)} again=${JSON.stringify(deleteExistingAgain.data)} never=${JSON.stringify(deleteNeverAdded.data)}`,
);
note(
'DELETE 的「逐字节相同」指 data 载荷:平台信封里的 meta.requestId 每个请求都不同(既有协议),' +
'因此比较的是 data 而非整段响应文本。',
);
// ---------- 8:后台幂等 ----------
const idempotencyKey = `theme-idem-${stamp}`;
const idemBody = {
name: `主题幂等 ${suffix}`,
summary: '同键重放',
badge: 'I',
sortOrder: 4,
status: 'published',
};
const idemFirst = await adminCreateTheme({
admin,
key: idempotencyKey,
body: idemBody,
});
const idemReplay = await adminCreateTheme({
admin,
key: idempotencyKey,
body: idemBody,
});
const idemThemeId = idemFirst.data?.theme?.themeId;
const adminAll = await adminListThemes({ admin, limit: 200 });
const sameNameCount = (adminAll.data?.themes ?? []).filter(
(theme) => theme.name === idemBody.name,
).length;
check(
'8) 同键重放 → replayed=true 且 themeId 相同',
idemFirst.status === 200 &&
idemReplay.status === 200 &&
idemFirst.data?.replayed === false &&
idemReplay.data?.replayed === true &&
idemReplay.data?.theme?.themeId === idemThemeId,
`first=${idemFirst.data?.replayed} replay=${idemReplay.data?.replayed}`,
);
check(
'8) 同键重放不产生第二个主题(同名主题在后台列表中只有 1 条)',
sameNameCount === 1,
`sameName=${sameNameCount}`,
);
const idemConflict = await adminCreateTheme({
admin,
key: idempotencyKey,
body: { ...idemBody, name: `主题幂等改名 ${suffix}` },
});
check(
'8) 同键换请求 → 409 THEME_IDEMPOTENCY_CONFLICT',
idemConflict.status === 409 &&
errorCode(idemConflict) === 'THEME_IDEMPOTENCY_CONFLICT',
brief(idemConflict),
);
const missingKey = await api('/admin/api/game-distribution/themes', {
method: 'POST',
token: admin,
body: { name: `缺键主题 ${suffix}`, status: 'draft' },
});
check(
'8) 创建主题缺 Idempotency-Key → 400',
missingKey.status === 400 && errorCode(missingKey) === 'BAD_REQUEST',
brief(missingKey),
);
// ---------- 9:公开路由匿名可读 + no-store ----------
for (const [label, pathname] of [
['主题列表', '/api/game-distribution/themes'],
['主题详情', `/api/game-distribution/themes/${t1Id}`],
['作品详情', `/api/game-distribution/games/${rootId}`],
]) {
const response = await api(pathname);
check(
`9) 匿名 ${label} 200 且带 Cache-Control: no-store`,
response.status === 200 && response.cacheControl.includes('no-store'),
`status=${response.status} cache-control=${response.cacheControl || '∅'}`,
);
}
// ---------- 10:后台成员名单(含不可见成员 / 草稿主题 / 分页 / 错误码) ----------
// 契约:`admin_list_theme_members`(不套公开可见性过滤、draft/archived 也能读)、
// `totalMembers` 是成员行总数(不受分页影响)、`visibility ∈ missing|deleted|published|unpublished|suspended`、
// `visible` 复用公开侧同一份判定、分页沿用主题列表的 20/50 与游标解析。
const bulkTheme = await adminCreateTheme({
admin,
key: `theme-bulk-${stamp}`,
body: {
name: `主题批量成员 ${suffix}`,
summary: '成员行数 > 50、可见成员为 0',
badge: 'B',
sortOrder: 8,
status: 'published',
},
});
const bulkThemeId = bulkTheme.data?.theme?.themeId;
check('10) 批量成员主题创建成功', Boolean(bulkThemeId), brief(bulkTheme));
// 用「未公开草稿作品」当成员:可见成员恒为 0,但成员行数 > 50,用来验证
// memberCount 只数可见成员、后台名单 totalMembers 数行且分页可翻。
let reusableCoverAssetId = await uploadCover(author.token, `${stamp}-bulk`);
const bulkRootIds = [];
let reuseNoted = false;
for (let index = 0; index < 51; index += 1) {
const title = `批量成员根作品 ${suffix}-${index}`;
let created = await createGame({
token: author.token,
metadata: gameMetadata({ title, coverAssetId: reusableCoverAssetId }),
idemKey: `theme-bulk-root-${index}-${stamp}`,
});
if (created.status !== 200) {
if (!reuseNoted) {
note(
`封面 assetId 不能跨作品复用时改为逐件上传(首次失败:${brief(created)})`,
);
reuseNoted = true;
}
reusableCoverAssetId = await uploadCover(
author.token,
`${stamp}-bulk-${index}`,
);
created = await createGame({
token: author.token,
metadata: gameMetadata({ title, coverAssetId: reusableCoverAssetId }),
idemKey: `theme-bulk-root-retry-${index}-${stamp}`,
});
}
const id = created.data?.id;
if (!id) {
check(
'10) 批量根作品创建成功',
false,
`index=${index} ${brief(created)}`,
);
break;
}
bulkRootIds.push(id);
const memberResponse = await adminPutMember({
admin,
themeId: bulkThemeId,
rootGameId: id,
sortOrder: index,
});
if (memberResponse.status !== 200) {
check(
'10) 批量成员写入成功',
false,
`index=${index} ${brief(memberResponse)}`,
);
break;
}
}
check(
'10) 51 个未公开根作品加入同一主题(成员行数 > 50)',
bulkRootIds.length === 51,
`rows=${bulkRootIds.length}`,
);
const bulkPublicDetail = await publicThemeDetail(bulkThemeId);
check(
'10) memberCount 只数可见成员:51 行不可见成员 → memberCount=0、roots=[]、rootsTruncated=false',
bulkPublicDetail.status === 200 &&
bulkPublicDetail.data?.memberCount === 0 &&
bulkPublicDetail.data?.roots?.length === 0 &&
bulkPublicDetail.data?.rootsTruncated === false,
`memberCount=${bulkPublicDetail.data?.memberCount} roots=${bulkPublicDetail.data?.roots?.length} rootsTruncated=${JSON.stringify(bulkPublicDetail.data?.rootsTruncated)}`,
);
note(
'未覆盖(成本):>50 个**可见**成员导致 roots 真被截断、rootsTruncated=true 的真实数据用例——' +
'需要 51 个走完整发布链路的公开作品。该分支由 module-game-distribution/src/theme.rs 里 ' +
'`game_distribution_theme_roots_truncated` 的单测(51/50 → true)覆盖;本脚本只覆盖了' +
'「行数 >50、可见 0」的对照面(rootsTruncated=false)。',
);
// 软删除成员:行仍在 → visible=false 且 visibility=deleted
const softDeletedRoot = await createGame({
token: author.token,
metadata: gameMetadata({
title: `软删除成员 ${suffix}`,
coverAssetId: await uploadCover(author.token, `${stamp}-soft`),
}),
idemKey: `theme-soft-root-${stamp}`,
});
const softDeletedRootId = softDeletedRoot.data?.id;
if (!softDeletedRootId) {
check('10) 软删除 fixture 作品创建成功', false, brief(softDeletedRoot));
}
const softDeleted = softDeletedRootId
? await softDeleteGame({
token: author.token,
gameId: softDeletedRootId,
revision: softDeletedRoot.data?.publicationRevision ?? 0,
key: `theme-soft-delete-${stamp}`,
})
: { status: 0, text: 'fixture 未创建' };
if (softDeletedRootId) {
await adminPutMember({
admin,
themeId: bulkThemeId,
rootGameId: softDeletedRootId,
sortOrder: 100,
});
}
check(
'10) 软删除 fixture 作品就绪',
softDeleted.status === 200,
brief(softDeleted),
);
const bulkMembers = await adminThemeMembers({
admin,
themeId: bulkThemeId,
limit: 999,
});
const bulkRows = bulkMembers.data?.members ?? [];
check(
'10) 行数 >50 时 limit=999 截断到 50(不是错误),totalMembers 仍是行总数 52,且仍有下一页',
bulkMembers.status === 200 &&
bulkRows.length === 50 &&
bulkMembers.data?.totalMembers === 52 &&
typeof bulkMembers.data?.nextCursor === 'string',
`rows=${bulkRows.length} totalMembers=${bulkMembers.data?.totalMembers} nextCursor=${JSON.stringify(bulkMembers.data?.nextCursor ?? null).slice(0, 30)}`,
);
check(
'10) totalMembers 不受分页影响:limit=1 时 members 只有 1 行但 totalMembers 仍为 52',
(await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data
?.totalMembers === 52 &&
(await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data
?.members?.length === 1,
`totalMembers=${(await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data?.totalMembers}`,
);
const unpublishedRow = bulkRows.find(
(member) => member.rootGameId === bulkRootIds[0],
);
check(
'10) 未公开成员照实返回:visible=false 且 visibility=unpublished',
unpublishedRow?.visible === false &&
unpublishedRow?.visibility === 'unpublished',
`visible=${JSON.stringify(unpublishedRow?.visible)} visibility=${unpublishedRow?.visibility}`,
);
check(
'10) 成员行键集合恰为契约的六个(visible 与 visibility 都在)',
bulkRows.length > 0 &&
JSON.stringify(Object.keys(bulkRows[0]).sort()) ===
JSON.stringify(
[
'createdAt',
'rootGameId',
'sortOrder',
'title',
'visibility',
'visible',
].sort(),
),
`keys=${Object.keys(bulkRows[0] ?? {}).join(',')}`,
);
check(
'10) 成员行按 sortOrder 升序返回',
bulkRows.every(
(row, index) =>
index === 0 || bulkRows[index - 1].sortOrder <= row.sortOrder,
),
`sortOrders=${bulkRows
.slice(0, 5)
.map((row) => row.sortOrder)
.join(',')}…`,
);
const memberPageIds = [];
const memberRows = [];
let memberCursor;
let memberPages = 0;
let lastMemberCursor = 'unset';
for (;;) {
const page = await adminThemeMembers({
admin,
themeId: bulkThemeId,
limit: 20,
...(memberCursor ? { cursor: memberCursor } : {}),
});
if (page.status !== 200) {
check('10) 成员名单游标翻页每页 200', false, brief(page));
break;
}
memberPages += 1;
const pageRows = page.data?.members ?? [];
memberRows.push(...pageRows);
memberPageIds.push(...pageRows.map((row) => row.rootGameId));
lastMemberCursor = page.data?.nextCursor ?? null;
if (!lastMemberCursor) break;
memberCursor = lastMemberCursor;
if (memberPages > 10) {
check(
'10) 成员名单游标翻页在合理页数内结束',
false,
`pages=${memberPages}`,
);
break;
}
}
check(
'10) 成员名单游标翻页不重不漏(52 行分 3 页,末页 nextCursor=null)',
memberPages === 3 &&
memberPageIds.length === 52 &&
new Set(memberPageIds).size === 52 &&
lastMemberCursor === null &&
bulkRootIds.every((id) => memberPageIds.includes(id)),
`pages=${memberPages} total=${memberPageIds.length} unique=${new Set(memberPageIds).size} lastCursor=${JSON.stringify(lastMemberCursor)}`,
);
const softDeletedRow = memberRows.find(
(member) => member.rootGameId === softDeletedRootId,
);
check(
'10) 已软删除成员照实返回:visible=false 且 visibility=deleted(行未删除,末页仍能翻到)',
softDeletedRow?.visible === false &&
softDeletedRow?.visibility === 'deleted',
`visible=${JSON.stringify(softDeletedRow?.visible)} visibility=${softDeletedRow?.visibility ?? '∅'}(该行 sortOrder=100,落最后一页)`,
);
const badMemberCursor = await adminThemeMembers({
admin,
themeId: bulkThemeId,
cursor: 'abc',
});
check(
'10) 成员名单非法游标 → 400 THEME_INVALID_CURSOR(与主题列表同一套解析)',
badMemberCursor.status === 400 &&
errorCode(badMemberCursor) === 'THEME_INVALID_CURSOR',
brief(badMemberCursor),
);
const missingThemeMembers = await adminThemeMembers({
admin,
themeId: `theme_${'0'.repeat(32)}`,
});
check(
'10) 主题不存在 → 404',
missingThemeMembers.status === 404,
brief(missingThemeMembers),
);
const draftThemeMembers = await adminThemeMembers({
admin,
themeId: draftId,
limit: 20,
});
check(
'10) draft 主题也能读到成员名单(该接口存在的理由:公开侧看不见、运营要能看)',
draftThemeMembers.status === 200 &&
(draftThemeMembers.data?.members ?? []).some(
(member) => member.rootGameId === rootId,
) &&
draftThemeMembers.data?.totalMembers === 1,
`status=${draftThemeMembers.status} totalMembers=${draftThemeMembers.data?.totalMembers}`,
);
const anonymousMembers = await api(
`/admin/api/game-distribution/themes/${bulkThemeId}/members`,
);
const strangerMembers = await api(
`/admin/api/game-distribution/themes/${bulkThemeId}/members`,
{ token: stranger.token },
);
check(
'10) 未登录读成员名单 → 401;普通作者 token → 403',
anonymousMembers.status === 401 && strangerMembers.status === 403,
`anonymous=${anonymousMembers.status} stranger=${strangerMembers.status}`,
);
note(
'未覆盖:`visibility=missing`(游戏行本身不存在)与「visibility=published 但无当前公开版本 → visible=false」' +
'两个防御性分支——前者需要删游戏行、后者需要在 kept-published 状态下清空当前公开版本,现有 HTTP 面都构造不出来;' +
'两者在 `game_distribution_theme_member_visibility` / `game_distribution_theme_member_visible` 的单测里覆盖。',
);
note(
'未覆盖:本脚本不做主题成员的可见性上限(详情 roots 受 50 条响应上限约束)与「同一个根重复加入两个主题后删除其一的读取一致性」之外的并发场景;' +
'分页排序键(created_at 倒序 + themeId 兜底)只通过「不重不漏」间接验证,未构造同微秒创建的主题对。',
);
console.log(
`[theme-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`,
);
if (failures > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(`[theme-e2e] 未捕获异常:${error?.stack ?? error}`);
process.exitCode = 1;
});