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

770 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发「收藏(收录)」链路真实行为验收。
//
// 需要完整本地 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',
`<!doctype html><html><head><meta charset="utf-8"><title>${marker}</title></head><body><h1>${marker}</h1></body></html>`,
);
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
return {
bytes,
fileCount: 1,
sha256: createHash('sha256').update(bytes).digest('hex'),
};
}
/// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。
async function publishToPublic({
token,
admin,
gameId,
gameRevision,
metadata,
stamp,
tag,
label,
}) {
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) 公开目录条目是匿名详情的子集(共有键取值全一致),详情仅多出 themes 这一处增量',
Boolean(catalogEntry) &&
mismatchedCatalogKeys.length === 0 &&
anonymousKeys.filter((key) => !catalogKeys.includes(key)).length === 1 &&
anonymousKeys.filter((key) => !catalogKeys.includes(key))[0] ===
'themes' &&
Array.isArray(anonymousDetail.data?.themes),
`catalogKeys=${catalogKeys.length} detailKeys=${anonymousKeys.length} 不一致=${mismatchedCatalogKeys.join(',') || '无'} 仅详情有=${anonymousKeys.filter((key) => !catalogKeys.includes(key)).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;
});