Files
Genarrative/scripts/check-game-distribution-collection-e2e.mjs
T
suzmii eb7349ef3d
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
test(游戏共创): 新增收藏(收录)端到端验收脚本
- 新增 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
2026-10-06 01:30:53 +08:00

767 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发「收藏(收录)」链路真实行为验收。
//
// 需要完整本地 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) 公开目录里的同作品条目与匿名详情键集合与取值都一致',
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;
});