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
- 新增 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
767 lines
26 KiB
JavaScript
767 lines
26 KiB
JavaScript
// 游戏分发「收藏(收录)」链路真实行为验收。
|
||
//
|
||
// 需要完整本地 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;
|
||
});
|