fa9ef9037f
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 37s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m27s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m25s
Project CI / Native shell tests (pull_request) Successful in 9m12s
三个发布 helper 补衍生作品必填 changeSummary(theme 脚本那 6 行是另一 session 已写好的,随本次一并提交);lineage 新增衍生作品 PUT 提升被拒 409 FORK_AUTHORIZATION_INHERITED 且档位不变、入口禁用态与未登录文案断言;新增贡献归集段(不变式 + 三链路传递 + 非作者 403 + 匿名 401)
1519 lines
51 KiB
JavaScript
1519 lines
51 KiB
JavaScript
// 游戏分发「共创主题」链路真实行为验收(公开读 + 后台写 + 作品详情 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):建版本 → 传发行包 → 送审 → 管理员通过。
|
||
///
|
||
/// `changeSummary` 只在**衍生作品**上传:它是衍生作品发布新版本的必填项(母版忽略)。
|
||
async function publishToPublic({
|
||
token,
|
||
admin,
|
||
gameId,
|
||
gameRevision,
|
||
metadata,
|
||
stamp,
|
||
tag,
|
||
label,
|
||
quiet = false,
|
||
changeSummary,
|
||
}) {
|
||
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,
|
||
...(changeSummary ? { changeSummary } : {}),
|
||
},
|
||
});
|
||
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,
|
||
// 衍生作品必填「本次核心改动说明」,母版忽略该字段。
|
||
changeSummary: 'E2E:主题用例里的衍生作品核心改动说明',
|
||
});
|
||
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;
|
||
});
|