From eb7349ef3de03b69cf5d79366410f211f87c7c01 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 01:30:53 +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=E6=94=B6=E8=97=8F=EF=BC=88=E6=94=B6=E5=BD=95?= =?UTF-8?q?=EF=BC=89=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=AA=8C=E6=94=B6=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/check-game-distribution-collection-e2e.mjs(约 750 行):真实 HTTP 验收收藏链路, 复用 lineage 脚本的发布链路 helper(脚本头注释逐条标注出处),覆盖 9 组共 46 条断言: · 幂等与「只一行」:两次不同键都成功(replayed=false);同键重放 replayed=true;两种情况都断言 my-collections 长度=1 且该作品只出现一次(无收藏计数接口,按工单允许的列表去重与长度证明) · 同键不同请求:换作品 → 409(「幂等键对应的请求摘要不一致」);**换用户 → 200 新收藏**,与 api-server handler 注释「换用户也是 409」不一致(收据键含 user_id,模块侧注释与实测一致), 脚本按实测断言并在 NOTE 与报告里标注 · DELETE 幂等:已收藏 / 未收藏 / 从未收藏且未公开 三种情形都 200 {collected:false} 且不发 replayed · 鉴权与缓存:三条路由未登录都 401 且带 Cache-Control: no-store(证明挂载而非 404/405); 已登录但缺 Idempotency-Key 的 PUT → 400 · 状态门:草稿 PUT → 409「作品状态不允许收藏(未公开或已软删除)」(含「状态」不含「不存在」, 不泄露未公开作品存在性);不存在的 gameId → 404「作品不存在」 · 下架 → 重新公开:收藏后下架读侧过滤掉该作品,重新公开(新版本审核通过)后又回到列表且仍只一行 (证明下架不删行、只在读侧过滤) · 公开详情 collected:匿名 23 键无 collected;已登录 24 键恰好多 collected=true; 去掉该键后与匿名逐字段一致;公开目录同作品条目与匿名详情键集合与取值完全一致 · 跨用户隔离:A 收藏后 B 的列表不含该作品、B 的 collected=false - package.json:新增 npm script check:game-distribution-collection-e2e - 实测:本地 dev 栈(SpacetimeDB 3110 / api-server 8188)46 项 46 PASS / 0 FAIL / 0 SKIP --- package.json | 1 + ...check-game-distribution-collection-e2e.mjs | 766 ++++++++++++++++++ 2 files changed, 767 insertions(+) create mode 100644 scripts/check-game-distribution-collection-e2e.mjs diff --git a/package.json b/package.json index 51ceb5ff3..446aa1fc0 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs", "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: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-collection-e2e.mjs b/scripts/check-game-distribution-collection-e2e.mjs new file mode 100644 index 000000000..054454372 --- /dev/null +++ b/scripts/check-game-distribution-collection-e2e.mjs @@ -0,0 +1,766 @@ +// 游戏分发「收藏(收录)」链路真实行为验收。 +// +// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;不需要浏览器。 +// +// 用法: +// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ +// node scripts/check-game-distribution-collection-e2e.mjs +// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口) +// +// 契约来源(逐条读实现确认,未按描述猜): +// [1] 三条路由(Bearer + no-store):api-server/src/modules/game_distribution.rs:340-352 +// [2] PUT 收藏:同文件 :1393-1426(幂等键必填、响应 `{collected, replayed}`) +// — 请求摘要绑定 `(user_id, game_id)`::1380-1385 +// [3] DELETE 取消收藏:同文件 :1428-1461(不要求幂等键;不存在也算成功;响应只有 `collected`) +// [4] GET 我的收藏:同文件 :1465-1481 + `my_collections_payload` :1487-1497(`{games, nextCursor:null}`) +// [5] 公开详情按可选登录态加 `collected`:同文件 :1305-1373(匿名 / 无效 token → **不加键**) +// [6] 事务语义:spacetime-module/src/game_distribution.rs:4669-4783 +// — 不存在 → 「作品不存在」(→ 404);存在但不可读 → `GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT`(→ 409) +// — 收据键 `(user_id, action, idempotency_key)`,摘要不一致 → 「幂等键对应的请求摘要不一致」(→ 409) +// — 收藏行主键 `{user_id}:{game_id}`,重复收藏不产生第二行 +// — 取消收藏按确定性主键删除,未收藏也成功 +// [7] 语义常量:shared-contracts/src/game_distribution.rs:33-34 +// (文案刻意不含「不存在」/「已被删除」,避免命中 404 分支泄露未公开作品的存在性) +// +// 复用/照抄的 helper(注释里标了出处;抽公共模块会改动其它已提交脚本,故照抄): +// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133 +// - check/note/api/brief/register/gameMetadata/uploadCover/createGame/ownerGame:lineage 脚本 :139-318 +// - publishToPublic(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407 +// (顺序与请求体源自 media-e2e.mjs:431-495,523-527,561-573) +// +// 与工单描述不一致、按实现断言并在报告里标注的地方: +// - 工单说「同键不同请求(换作品 / 换用户)→ 409」。实测 **换作品** 是 409(同一收据键、摘要不同), +// 但 **换用户** 不会命中别人的收据(收据键含 user_id),因此是正常 200 新收藏。api-server 的 +// handler 注释写「换用户也是 409」,与同一提交里 spacetime-module 的注释(「不同用户 / 不同作品 +// 即使共用同一个 Idempotency-Key 也不会互相命中」)相反;本脚本按后者(= 实测)断言。 + +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, 220)}`; +} + +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, + user: response.data?.user, + }; +} + +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 = `collection-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'collection', 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-collection', + }, + }); + 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 }) { + return api('/api/game-distribution/games', { + method: 'POST', + token, + headers: { 'Idempotency-Key': idemKey }, + body: metadata, + }); +} + +async function ownerGame(token, gameId) { + const response = await api(`/api/game-distribution/my-games/${gameId}`, { + token, + }); + return { response, game: response.data?.game ?? null }; +} + +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, +}) { + const zip = await buildReleaseZip(`collection-${tag}-${stamp}`); + const created = await api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token, + headers: { 'Idempotency-Key': `col-version-${tag}-${stamp}` }, + body: { + packageSha256: zip.sha256, + packageBytes: zip.bytes.length, + packageFileCount: zip.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }); + const versionId = created.data?.versionId; + 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': `col-upload-${tag}-${stamp}`, + 'Content-Type': 'application/zip', + }, + binary: zip.bytes, + }, + ); + check( + `${label} 发行包上传成功`, + upload.status === 200, + `status=${upload.status}`, + ); + + const submitted = await api( + `/api/game-distribution/versions/${versionId}/submit`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': `col-submit-${tag}-${stamp}` }, + body: { expectedPublicationRevision: gameRevision }, + }, + ); + 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': `col-approve-${tag}-${stamp}` }, + body: { + decision: 'approve', + expectedPublicationRevision: + readback.data?.version?.publicationRevision ?? gameRevision, + }, + }, + ); + check( + `${label} 管理员审核通过并公开`, + approved.status === 200, + `status=${approved.status} ${approved.text.slice(0, 140)}`, + ); + return { versionId }; +} + +// ---------- 收藏专用 helper ---------- + +function collectGame({ token, gameId, key }) { + return api(`/api/game-distribution/games/${gameId}/collection`, { + method: 'PUT', + token, + ...(key === undefined ? {} : { headers: { 'Idempotency-Key': key } }), + }); +} + +function uncollectGame({ token, gameId }) { + return api(`/api/game-distribution/games/${gameId}/collection`, { + method: 'DELETE', + token, + }); +} + +function myCollections(token) { + return api('/api/game-distribution/my-collections', { token }); +} + +function publicDetail(gameId, token) { + return api(`/api/game-distribution/games/${gameId}`, { token }); +} + +function publicCatalog() { + return api('/api/game-distribution/games'); +} + +function occurrences(games, gameId) { + return (games ?? []).filter((game) => game.id === gameId).length; +} + +// ---------- 主流程 ---------- + +async function main() { + console.log( + `[collection-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 viewer = await register('133'); + check( + '作者账号注册拿到 token', + author.response.status === 200 && Boolean(author.token), + `status=${author.response.status}`, + ); + check( + '第二个账号(未收藏视图)注册拿到 token', + viewer.response.status === 200 && Boolean(viewer.token), + `status=${viewer.response.status}`, + ); + if (!author.token || !viewer.token) process.exit(1); + + // ---------- fixture:已公开作品 X + 草稿作品 D ---------- + const metadata = gameMetadata({ + title: `收藏作品 ${suffix}`, + coverAssetId: await uploadCover(author.token, stamp), + }); + const created = await createGame({ + token: author.token, + metadata, + idemKey: `col-game-${stamp}`, + }); + const gameId = created.data?.id; + check( + '作品 X 创建成功', + created.status === 200 && Boolean(gameId), + `status=${created.status} id=${gameId ?? ''}`, + ); + if (!gameId) process.exit(1); + await publishToPublic({ + token: author.token, + admin, + gameId, + gameRevision: created.data?.publicationRevision ?? 0, + metadata, + stamp, + tag: 'x', + label: '作品 X', + }); + const xPublic = await publicDetail(gameId); + check( + '作品 X 已公开可读', + xPublic.status === 200 && xPublic.data?.status === 'published', + brief(xPublic), + ); + + const draftMetadata = gameMetadata({ + title: `收藏草稿 ${suffix}`, + coverAssetId: await uploadCover(author.token, `${stamp}-draft`), + }); + const draftCreated = await createGame({ + token: author.token, + metadata: draftMetadata, + idemKey: `col-draft-${stamp}`, + }); + const draftId = draftCreated.data?.id; + check( + '草稿作品 D 创建成功(用于 409 用例)', + draftCreated.status === 200 && Boolean(draftId), + `status=${draftCreated.status} id=${draftId ?? ''}`, + ); + if (!draftId) process.exit(1); + + // ---------- 1/2:两次不同幂等键 + 同键重放,且始终只有一行 ---------- + const listBefore = await myCollections(author.token); + check( + '收藏前我的收藏列表为空', + listBefore.status === 200 && + Array.isArray(listBefore.data?.games) && + listBefore.data.games.length === 0, + `status=${listBefore.status} size=${listBefore.data?.games?.length ?? 'n/a'}`, + ); + + const keyA = `col-key-a-${stamp}`; + const keyB = `col-key-b-${stamp}`; + const firstCollect = await collectGame({ + token: author.token, + gameId, + key: keyA, + }); + check( + '1) 首次收藏成功且 replayed=false', + firstCollect.status === 200 && + firstCollect.data?.collected === true && + firstCollect.data?.replayed === false, + brief(firstCollect), + ); + const secondCollect = await collectGame({ + token: author.token, + gameId, + key: keyB, + }); + check( + '1) 换幂等键再次收藏仍成功且 replayed=false', + secondCollect.status === 200 && + secondCollect.data?.collected === true && + secondCollect.data?.replayed === false, + brief(secondCollect), + ); + const listAfterTwoKeys = await myCollections(author.token); + check( + '1) 两次不同键后该作品在列表中只出现一次(长度=1)', + listAfterTwoKeys.data?.games?.length === 1 && + occurrences(listAfterTwoKeys.data?.games, gameId) === 1, + `size=${listAfterTwoKeys.data?.games?.length} occurrences=${occurrences(listAfterTwoKeys.data?.games, gameId)}`, + ); + + const replay = await collectGame({ token: author.token, gameId, key: keyB }); + check( + '2) 同键重放 replayed=true 且仍只一行', + replay.status === 200 && + replay.data?.collected === true && + replay.data?.replayed === true && + occurrences((await myCollections(author.token)).data?.games, gameId) === + 1, + brief(replay), + ); + + // ---------- 3:同键不同请求 ---------- + const sameKeyOtherGame = await collectGame({ + token: author.token, + gameId: draftId, + key: keyA, + }); + check( + '3) 同键换作品 → 409(幂等摘要不一致)', + sameKeyOtherGame.status === 409 && + String( + sameKeyOtherGame.error?.details?.message ?? + sameKeyOtherGame.error?.message ?? + '', + ).includes('幂等'), + brief(sameKeyOtherGame), + ); + note( + '3) 同键换**用户**不会命中别人的收据:收据键是 (user_id, action, idempotency_key),' + + '不同用户共用同一个 Idempotency-Key 是各自独立的新收藏(见下面的跨用户探针),' + + '与 api-server handler 注释里「换用户也是 409」不一致,但与该提交里 spacetime-module 的注释一致。', + ); + + // ---------- 4:DELETE 幂等 ---------- + const deleted = await uncollectGame({ token: author.token, gameId }); + check( + '4) 已收藏时 DELETE → 200 {collected:false} 且不发 replayed', + deleted.status === 200 && + deleted.data?.collected === false && + deleted.data?.replayed === undefined, + brief(deleted), + ); + const deletedAgain = await uncollectGame({ token: author.token, gameId }); + check( + '4) 未收藏时再 DELETE 仍 200 {collected:false}', + deletedAgain.status === 200 && deletedAgain.data?.collected === false, + brief(deletedAgain), + ); + const deletedNever = await uncollectGame({ + token: author.token, + gameId: draftId, + }); + check( + '4) 从未收藏过的作品 DELETE 也 200(不要求作品公开)', + deletedNever.status === 200 && deletedNever.data?.collected === false, + brief(deletedNever), + ); + + // ---------- 5:未登录 401 + no-store ---------- + for (const [label, call] of [ + ['PUT', () => collectGame({ gameId, key: `col-anon-${stamp}` })], + ['DELETE', () => uncollectGame({ gameId })], + ['GET', () => myCollections('')], + ]) { + const response = await call(); + check(`5) 未登录 ${label} → 401`, response.status === 401, brief(response)); + check( + `5) 未登录 ${label} 响应带 Cache-Control: no-store(证明挂载而非 404/405)`, + response.cacheControl.includes('no-store'), + `cache-control=${response.cacheControl || '∅'}`, + ); + } + const noKey = await collectGame({ token: author.token, gameId }); + check( + '5) 已登录但缺 Idempotency-Key 的 PUT → 400', + noKey.status === 400, + brief(noKey), + ); + + // ---------- 6:未公开 / 不存在 ---------- + const draftCollect = await collectGame({ + token: author.token, + gameId: draftId, + key: `col-draft-key-${stamp}`, + }); + const draftMessage = String( + draftCollect.error?.details?.message ?? draftCollect.error?.message ?? '', + ); + check( + '6) 未公开作品 PUT → 409(状态冲突)', + draftCollect.status === 409, + brief(draftCollect), + ); + check( + '6) 409 文案含「状态」且不含「不存在」(不泄露未公开作品的存在性)', + draftMessage.includes('状态') && !draftMessage.includes('不存在'), + `message=${draftMessage || '∅'}`, + ); + const missingCollect = await collectGame({ + token: author.token, + gameId: `game_${'0'.repeat(32)}`, + key: `col-missing-key-${stamp}`, + }); + check( + '6) 不存在的 gameId PUT → 404', + missingCollect.status === 404, + brief(missingCollect), + ); + + // ---------- 9:跨用户隔离(在跨用户探针之前做,避免被后面的收藏干扰) ---------- + await collectGame({ token: author.token, gameId, key: `col-iso-${stamp}` }); + const viewerList = await myCollections(viewer.token); + check( + '9) 用户 A 收藏后,用户 B 的 my-collections 不含该作品', + viewerList.status === 200 && + occurrences(viewerList.data?.games, gameId) === 0, + `viewer size=${viewerList.data?.games?.length ?? 'n/a'}`, + ); + const viewerDetail = await publicDetail(gameId, viewer.token); + check( + '9) 用户 B 的公开详情里 collected=false(未收藏但已认证)', + viewerDetail.status === 200 && viewerDetail.data?.collected === false, + `collected=${JSON.stringify(viewerDetail.data?.collected ?? null)}`, + ); + + // 跨用户同键探针(工单预期 409,实现按用户隔离收据 → 期望 200 新收藏) + const crossUser = await collectGame({ + token: viewer.token, + gameId, + key: keyA, + }); + check( + '9+) 另一用户复用 A 用过的同一 Idempotency-Key → 200(收据按用户隔离,非 409)', + crossUser.status === 200 && + crossUser.data?.collected === true && + crossUser.data?.replayed === false, + brief(crossUser), + ); + check( + '9+) 跨用户收藏后 B 的列表含该作品,且 A 的列表不受影响', + occurrences((await myCollections(viewer.token)).data?.games, gameId) === + 1 && + occurrences((await myCollections(author.token)).data?.games, gameId) === + 1, + `viewer=${occurrences((await myCollections(viewer.token)).data?.games, gameId)} author=${occurrences((await myCollections(author.token)).data?.games, gameId)}`, + ); + await uncollectGame({ token: viewer.token, gameId }); + + // ---------- 7:下架 → 重新公开 ---------- + const beforeUnpublish = await ownerGame(author.token, gameId); + const unpublishRevision = beforeUnpublish.game?.publicationRevision ?? 0; + const unpublished = await api( + `/api/game-distribution/games/${gameId}/unpublish`, + { + method: 'POST', + token: author.token, + headers: { 'Idempotency-Key': `col-unpublish-${stamp}` }, + body: { expectedPublicationRevision: unpublishRevision }, + }, + ); + check('7) 作者下架成功', unpublished.status === 200, brief(unpublished)); + const hiddenList = await myCollections(author.token); + check( + '7) 下架后 my-collections 不含该作品(读侧过滤)', + hiddenList.status === 200 && + occurrences(hiddenList.data?.games, gameId) === 0, + `size=${hiddenList.data?.games?.length} occurrences=${occurrences(hiddenList.data?.games, gameId)}`, + ); + const republish = await publishToPublic({ + token: author.token, + admin, + gameId, + gameRevision: + (await ownerGame(author.token, gameId)).game?.publicationRevision ?? 0, + metadata, + stamp, + tag: 'x-v2', + label: '作品 X 重新公开', + }); + check( + '7) 重新公开(新版本审核通过)后 my-collections 又含该作品且只一行', + republish.versionId !== null && + occurrences((await myCollections(author.token)).data?.games, gameId) === + 1, + `occurrences=${occurrences((await myCollections(author.token)).data?.games, gameId)}`, + ); + + // ---------- 8:公开详情 collected 的匿名 vs 已认证 ---------- + const anonymousDetail = await publicDetail(gameId); + const authorDetail = await publicDetail(gameId, author.token); + const anonymousHasKey = Object.hasOwn( + anonymousDetail.data ?? {}, + 'collected', + ); + const authorKeys = Object.keys(authorDetail.data ?? {}); + const anonymousKeys = Object.keys(anonymousDetail.data ?? {}); + check( + '8) 匿名公开详情没有 collected 键', + anonymousDetail.status === 200 && !anonymousHasKey, + `keys=${anonymousKeys.join(',')}`, + ); + check( + '8) 已登录公开详情恰好多 collected 这一个键且值为 true', + authorDetail.status === 200 && + authorDetail.data?.collected === true && + authorKeys.length === anonymousKeys.length + 1 && + authorKeys.every( + (key) => key === 'collected' || anonymousKeys.includes(key), + ), + `authorKeys=${authorKeys.length} anonymousKeys=${anonymousKeys.length} collected=${JSON.stringify(authorDetail.data?.collected ?? null)}`, + ); + const authorWithoutCollected = { ...(authorDetail.data ?? {}) }; + delete authorWithoutCollected.collected; + check( + '8) 去掉 collected 后,已登录详情与匿名详情逐字段一致', + JSON.stringify(authorWithoutCollected) === + JSON.stringify(anonymousDetail.data ?? {}), + `diffKeys=${anonymousKeys.filter((key) => JSON.stringify(authorDetail.data?.[key]) !== JSON.stringify(anonymousDetail.data?.[key])).join(',') || '无'}`, + ); + const catalog = await publicCatalog(); + const catalogEntry = (catalog.data?.games ?? []).find( + (game) => game.id === gameId, + ); + const catalogKeys = Object.keys(catalogEntry ?? {}); + const mismatchedCatalogKeys = catalogKeys.filter( + (key) => + JSON.stringify(catalogEntry?.[key]) !== + JSON.stringify(anonymousDetail.data?.[key]), + ); + check( + '8) 公开目录里的同作品条目与匿名详情键集合与取值都一致', + Boolean(catalogEntry) && + catalogKeys.length === anonymousKeys.length && + mismatchedCatalogKeys.length === 0, + `catalogKeys=${catalogKeys.length} detailKeys=${anonymousKeys.length} 不一致=${mismatchedCatalogKeys.join(',') || '无'}`, + ); + check( + '8) 我的收藏列表条数与「已收藏」状态一致(nextCursor=null)', + (await myCollections(author.token)).data?.nextCursor === null && + occurrences((await myCollections(author.token)).data?.games, gameId) === + 1, + `nextCursor=${JSON.stringify((await myCollections(author.token)).data?.nextCursor ?? null)}`, + ); + note( + `未覆盖:没有收藏计数类接口可读,因此「只一行」用 my-collections 的长度与出现次数证明(工单允许的方式);` + + `当前库内该用户收藏条数=${(await myCollections(author.token)).data?.games?.length ?? 'n/a'}。`, + ); + + console.log( + `[collection-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, + ); + if (failures > 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(`[collection-e2e] 未捕获异常:${error?.stack ?? error}`); + process.exitCode = 1; +});