// 游戏分发「收藏(收录)」链路真实行为验收。
//
// 需要完整本地 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) 公开目录条目是匿名详情的子集(共有键取值全一致),详情仅多出 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;
});