From 58da0e67dbc6214a70d474ff3bffae88f2cc126d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 03:44:31 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=B1=E5=88=9B=E4=B8=BB=E9=A2=98=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E9=AA=8C=E6=94=B6=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- package.json | 1 + scripts/check-game-distribution-theme-e2e.mjs | 1177 +++++++++++++++++ 2 files changed, 1178 insertions(+) create mode 100644 scripts/check-game-distribution-theme-e2e.mjs diff --git a/package.json b/package.json index 446aa1fc0..769fb4119 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "check:game-distribution-lineage-e2e": "node scripts/check-game-distribution-lineage-e2e.mjs", "check:game-distribution-project-bundle-e2e": "node scripts/check-game-distribution-project-bundle-e2e.mjs", "check:game-distribution-collection-e2e": "node scripts/check-game-distribution-collection-e2e.mjs", + "check:game-distribution-theme-e2e": "node scripts/check-game-distribution-theme-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-theme-e2e.mjs b/scripts/check-game-distribution-theme-e2e.mjs new file mode 100644 index 000000000..3027b5f61 --- /dev/null +++ b/scripts/check-game-distribution-theme-e2e.mjs @@ -0,0 +1,1177 @@ +// 游戏分发「共创主题」链路真实行为验收(公开读 + 后台写 + 作品详情 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', + `${marker}

${marker}

`, + ); + 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; +});