Files
Genarrative/scripts/check-game-distribution-theme-e2e.mjs
T
suzmii 58da0e67db
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (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 2/2 (pull_request) Has been cancelled
test(游戏共创): 新增共创主题端到端验收脚本
- 新增 scripts/check-game-distribution-theme-e2e.mjs(约 1160 行):真库验收公开读 + 后台写 +
  作品详情 themes 增量,复用 lineage 脚本的完整发布链路 helper(脚本头注释逐条标注出处),
  覆盖 9 组共 54 条断言:
  · 建主题→加根成员→公开读:公开列表只含 published(且不含 status 字段)、详情 roots 逐条与公开
    目录同一投影(键集合 23=23)、memberCount 与 roots 长度一致
  · 只允许根:第 1 代子作品当成员 → 409 THEME_MEMBER_NOT_ROOT;不存在的 gameId → 404 THEME_MEMBER_GAME_NOT_FOUND
  · 跨主题多归属:同一个根加入两个主题,两条主题都能读到它,作品详情 themes 长度=2
  · 作品详情 themes 口径:匿名与登录都发;**专门构造第 1 代衍生作品**(带 fork 声明 + 完整发布链路)
    并断言它也返回根所属的两个主题(先取根再反查);无归属作品返回空数组
  · 可见性:draft 主题公开 404(后台按 draft 过滤仍可见)、archived 公开 404 且作品详情 themes 同步消失、
    改回 published 恢复;已发布但成员全不可见 → 200 + 空 roots + memberCount=0;
    成员下架 → 从 roots 消失,重新公开(新版本审核通过)→ 同一行自动回到 roots 且仍只一行
  · 分页:默认 20、limit=999 截断到 50、limit=20 游标翻页 3 页 54 条不重不漏、末页 nextCursor=null、
    ?cursor=abc → **400**(状态码符合契约)
  · 后台权限:五条路由未登录 401;普通作者 token 403;成员 PUT/DELETE 免幂等键、重复 PUT 只留一行
    且 createdAt 不变、DELETE 幂等(data 载荷逐字段相同,信封 meta.requestId 每请求不同属既有协议)
  · 后台幂等:同键重放 replayed=true 且不产生第二个主题、同键换请求 409 THEME_IDEMPOTENCY_CONFLICT、
    缺 Idempotency-Key → 400
  · 三条公开路由匿名 200 且带 Cache-Control: no-store
- **实测 53 PASS / 1 FAIL**:唯一失败是已确认的产品缺口(仅报告、未改后端)——非法游标的状态码是
  400 ✓,但稳定码 THEME_INVALID_CURSOR 不可达:api-server/...:5343-5346 为该码注册了 400 映射,
  而模块侧只回纯文案「主题列表游标格式无效」(module-game-distribution/src/theme.rs:205-215),
  不含分支前置所需的 `THEME_` 前缀,客户端实际拿到 code=BAD_REQUEST。脚本刻意保留这条红断言
  (契约真值),并附 NOTE 说明成因与证据。
- package.json:新增 npm script check:game-distribution-theme-e2e
2026-10-06 03:44:31 +08:00

1178 lines
38 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:2921-2930
// [2] 公开列表分页::1635-1690(limit 归一化 :1757-1764、cursor 由模块解析并 400 透传)
// [3] 公开详情::1670-1697(未发布/不存在都回「主题不存在」→ 404;已发布空成员是 200 + 空 roots)
// + module-game-distribution/src/theme.rs:105-108(公开可见 = status == published)
// [4] 详情成员与列表 memberCount 同一份可见性判定(未删 + 已公开 + 有当前公开版本):
// spacetime-module/src/game_distribution.rs:5455-5477 + theme.rs:131-147
// [5] 作品详情 `themes` 增量「先取根再反查」:spacetime-module/...:5620-5670 + 根解析 :5450-5456
// [6] 后台写路由(admin 鉴权):api-server/...:2887-2900;创建/更新幂等 :1780-1880;
// 成员 PUT/DELETE 不要求幂等键且确定性主键去重:spacetime-module/...:5890-5965
// [7] 错误码映射:api-server/...:5329-5365(THEME_NOT_FOUND→404、THEME_BAD_REQUEST→400、
// THEME_INVALID_CURSOR→400、THEME_IDEMPOTENCY_CONFLICT→409、THEME_MEMBER_NOT_ROOT→409、
// THEME_MEMBER_GAME_NOT_FOUND→404)
// [8] 分页极限:theme.rs:156-172(默认 20、上限 50、超界截断、0 取默认)+ 游标格式 `{micros}:{themeId}` :205-218
//
// 复用/照抄的 helper(注释标了出处;抽公共模块会改动其它已提交脚本,故照抄):
// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133
// - check/note/api/brief/register/gameMetadata/uploadCover/createGame/ownerGame/publicDetail:
// lineage 脚本 :139-318
// - 完整发布链路(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407
// - 改编声明(fork 字段)+ 公开版本 id 取法:lineage 脚本 :758-812
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 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}`,
);
// ---------- 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(
'产品缺口(未修,仅报告):实现为非法游标注册了稳定码 THEME_INVALID_CURSOR' +
'(api-server/...:5343-5346 + shared-contracts/src/game_distribution.rs:46),' +
'但模块侧只回纯文案「主题列表游标格式无效」(module-game-distribution/src/theme.rs:205-215),不含 THEME_ 前缀,' +
'而映射分支的前置条件是 message.contains("THEME_") → 该分支不可达,客户端实际拿到 code=BAD_REQUEST(状态码仍是 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 || '∅'}`,
);
}
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;
});