dcef9b62a8
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 7m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 7m48s
Project CI / Repository checks (pull_request) Successful in 5m36s
- admin.rs:钱包流水来源映射补齐第 17 个变体(索引 16 → game_purchase),后台「消耗泥点」白名单新增 game_purchase,并抽出效果分类与单行累加函数
- profile.rs:消耗口径抽出 profile_wallet_ledger_source_counts_as_consumption,消费投影增量、历史花费重建、投影初始化三处一并纳入 GamePurchase
- module-game-distribution/errors.rs:价格上限文案改为插值 MAX_GAME_PRICE_MUD_POINTS,并新增文案断言
- 新增 scripts/check-game-distribution-price-limit-parity.mjs 并挂进 npm run lint(随 Repository checks 在 CI 生效):断言 TS / 服务端 Rust / AGC Rust 常量与网页侧别名四处同源
- api-server 发行网关抽出可注入 loader 的 serve_public_release_asset,付费 404 严格发生在读取包字节之前;播放会话准入抽出 resolve_paid_play_session_entitlement
- 新增 api-server 单测:付费发行路径 404 且零次读包字节、免费放行且读到包字节、未购买 403 与作者 / 已购买 / 管理员放行
- spacetime-module:把「先判定后扣费」从源码扫描改为 resolve_game_purchase_pre_charge_plan 纯函数断言,删除 include_str 源码切片用例
- E2E 脚本:平台同源断言缺配置时记 SKIP 并在报告末尾汇总 SKIP 清单,E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,汇总行改为输出 PASS/FAIL/WARN/SKIP 计数
- 修正注释里不存在的路由 POST /api/game-distribution/play-sessions(真实为 POST /api/game-distribution/games/{gameId}/play-session)与「管理员令牌 403」口径(403 需带 admin 角色的用户令牌)
1657 lines
60 KiB
JavaScript
1657 lines
60 KiB
JavaScript
// 游戏买断制泥点付费与播放鉴权「真实本地栈」端到端检查。
|
||
//
|
||
// 定位:人工验收脚本,**不在 CI 自动门禁内**(需要真实 SpacetimeDB + api-server + 可用的
|
||
// OSS 凭据 + 管理员账号,冒烟时长与环境依赖都不适合放进流水线)。发布前由人工在本机
|
||
// 或联调机上执行,失败即非零退出。
|
||
//
|
||
// 用法:
|
||
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
|
||
// npm run check:game-distribution-purchase-e2e
|
||
// E2E_START_STACK=1 时脚本自行在仓库根执行 `npm run dev --no-interactive`,
|
||
// 等 `.app/dev-stack.json` 里的 api-server `/healthz` 就绪后跑用例,进程结束时
|
||
// 用 taskkill / SIGTERM 收掉自己拉起的整棵 dev 栈,不依赖人工清理。
|
||
// 默认不自行起服务:直接从仓库根 `.app/dev-stack.json` 读取实际端口(api-server 与
|
||
// 主站 Vite),端口漂移后仍以文件为准,不臆断 3000 / 8082 这类默认值。
|
||
// E2E_API_BASE / E2E_WEB_BASE 可显式覆盖两个地址;E2E_WEB_BASE 缺省且本地没有
|
||
// 主站 Vite 时,平台同源路径 `/games/<gameId>/` 记 SKIP 并在报告末尾汇总(既不算 PASS
|
||
// 也不算 FAIL);设置 E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,避免把「没验过」
|
||
// 当成通过。脚本末尾的汇总行同时给出 PASS / FAIL / WARN / SKIP 四项计数。
|
||
// E2E_PRICE_MUD_POINTS 覆盖付费游戏定价(默认 30)。
|
||
// E2E_ADMIN_USER / E2E_ADMIN_PASSWORD 必填,脚本不读取仓库内任何凭据文件。
|
||
// 「购买记录条数」用 `spacetime sql` 直读 `game_distribution_purchase` 做交叉验证;
|
||
// CLI 不可用时该项降级为 WARN,其余断言仍按 HTTP/账单证据判定。
|
||
//
|
||
// 覆盖:
|
||
// 1. 作者发布付费游戏(priceMudPoints=N)→ 管理员审核通过。
|
||
// 2. 未购买账号:公开详情有价格但 purchased=false 且无 entryUrl;直连发行网关与
|
||
// 平台同源 `/games/<gameId>/` 都是 404;请求播放会话 403。
|
||
// 3. 购买前记录钱包余额;购买成功扣 N 泥点、账单出现 game_purchase 流水且购买记录唯一。
|
||
// 4. 同 Idempotency-Key 重放与换 key 重复购买都只扣一次。
|
||
// 5. 购买后播放会话返回 playUrl,入口 200 且是同一发行包内容;重复进入不再扣费。
|
||
// 6. 播放会话网关来源约束:伪造令牌 404;带平台 refresh Cookie 直连 api-server 被 403
|
||
// 拒绝,但经 dev 代理(或生产 nginx `^~ /api/game-distribution/play-sessions/`)
|
||
// 按前缀清空 Cookie 后仍能播放;同时取证 refresh Cookie 的 Path 属性。
|
||
// 7. 免费游戏回归:公开入口直接可玩,播放会话不签发令牌。
|
||
// 8. (附加)余额不足时购买失败,余额、账单与购买记录都不变。
|
||
// 9. 作者本人免购买:详情有入口但 purchased=false、自购被 400 `GAME_PURCHASE_OWNER_EXEMPT`
|
||
// 拒绝且无扣费副作用、可直接创建播放会话。
|
||
// 10. 管理员免购买:管理员令牌可直接创建播放会话并可玩;购买被拒(当前真实链路是用户
|
||
// 鉴权前置的 401,`403 GAME_PURCHASE_ADMIN_NOT_ALLOWED` 需要带 admin 角色的用户令牌,
|
||
// 详见运行时 WARN 与交付说明)。
|
||
// 11. 真并发购买:同一未购买账号对同一付费游戏同时发起两个不同 Idempotency-Key 的购买,
|
||
// 断言无 5xx、只扣一次、game_purchase 流水与购买记录都只有 1 条、详情 purchased=true。
|
||
// 12. 审核员试玩待审付费版本:admin preview session 入口 200 且同包、可反复创建不限次、
|
||
// 钱包与购买记录不变。
|
||
// 13. 定价越界:priceMudPoints=1000001 被 400 拒绝且不落版本;0 与 1000000 边界可通过。
|
||
// 14. 下架后失效:作者下架后旧播放会话入口与包内资源 404、公开详情不再返回入口。
|
||
import { spawn, spawnSync } from 'node:child_process';
|
||
import { readFileSync, writeFileSync } from 'node:fs';
|
||
import { dirname, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import JSZip from 'jszip';
|
||
|
||
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
|
||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const DEV_STACK_PATH = resolve(REPO_ROOT, '.app/dev-stack.json');
|
||
const STACK_LOG_PATH = resolve(
|
||
REPO_ROOT,
|
||
'logs/e2e-game-distribution-purchase.log',
|
||
);
|
||
|
||
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
|
||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
|
||
if (!ADMIN_USER || !ADMIN_PASSWORD) {
|
||
console.error(
|
||
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:请用已配置管理员账号的环境变量运行,' +
|
||
'本地栈可先以 GENARRATIVE_ADMIN_USERNAME / GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
|
||
);
|
||
process.exit(2);
|
||
}
|
||
|
||
const PRICE_MUD_POINTS = Number.parseInt(
|
||
(process.env.E2E_PRICE_MUD_POINTS ?? '').trim() || '30',
|
||
10,
|
||
);
|
||
if (!Number.isInteger(PRICE_MUD_POINTS) || PRICE_MUD_POINTS <= 0) {
|
||
console.error('E2E_PRICE_MUD_POINTS 必须是正整数');
|
||
process.exit(2);
|
||
}
|
||
|
||
const START_STACK = (process.env.E2E_START_STACK ?? '').trim() === '1';
|
||
/**
|
||
* 设置后,平台同源断言缺少主站地址时直接 FAIL(而不是 SKIP)。
|
||
*
|
||
* 发布前验收或 CI 化演练应打开它:那时「没验过」必须当成失败,不能当成通过。
|
||
*/
|
||
const REQUIRE_PLATFORM_ORIGIN =
|
||
(process.env.E2E_REQUIRE_PLATFORM_ORIGIN ?? '').trim() === '1';
|
||
const GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE = 'game_purchase';
|
||
|
||
/** 1x1 透明 PNG:仅用于满足发布必填封面,内容不影响本用例断言。 */
|
||
const COVER_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||
'base64',
|
||
);
|
||
|
||
let failures = 0;
|
||
let passes = 0;
|
||
let warnings = 0;
|
||
/** 被跳过的断言:缺配置时既不算 PASS 也不算 FAIL,但必须在报告末尾显式列出。 */
|
||
const skips = [];
|
||
function check(name, ok, detail = '') {
|
||
if (ok) {
|
||
passes += 1;
|
||
} else {
|
||
failures += 1;
|
||
}
|
||
console.log(
|
||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||
);
|
||
}
|
||
|
||
function warn(name, detail = '') {
|
||
warnings += 1;
|
||
console.log(`WARN ${name}${detail ? ` :: ${detail}` : ''}`);
|
||
}
|
||
|
||
function skip(name, detail = '') {
|
||
skips.push({ name, detail });
|
||
console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`);
|
||
}
|
||
|
||
function readDevStack() {
|
||
try {
|
||
return JSON.parse(readFileSync(DEV_STACK_PATH, 'utf8'));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeBase(value) {
|
||
return String(value ?? '')
|
||
.trim()
|
||
.replace(/\/+$/u, '');
|
||
}
|
||
|
||
// api-server / 主站 Vite 的实际地址只认显式覆盖或 .app/dev-stack.json,不用默认端口兜底。
|
||
// 自起 dev 栈时端口由 dev 脚本探测决定,因此这两个地址要在就绪后重新解析一次。
|
||
let devStack = readDevStack();
|
||
let API = normalizeBase(
|
||
process.env.E2E_API_BASE ?? devStack?.services?.['api-server']?.url,
|
||
);
|
||
let WEB = '';
|
||
|
||
/**
|
||
* 解析主站 Vite 地址:显式覆盖优先,其次 dev-stack.json 里记录的 web 地址。
|
||
*
|
||
* 不能只看 `status`:dev.mjs 记录的 web 状态偶发滞后(进程实际在跑但字段仍是 failed),
|
||
* 因此这里以「该地址能不能响应 HTTP」为准,探不通才按未启动处理。
|
||
*/
|
||
async function resolveWebBase() {
|
||
const candidate = normalizeBase(
|
||
process.env.E2E_WEB_BASE ?? devStack?.services?.web?.url,
|
||
);
|
||
if (!candidate) return '';
|
||
// 自起 dev 栈时 Vite 在 api-server 之后启动,给它一个就绪窗口。
|
||
const deadline = Date.now() + (START_STACK ? 60_000 : 0);
|
||
for (;;) {
|
||
try {
|
||
await fetch(`${candidate}/`);
|
||
return candidate;
|
||
} catch {
|
||
if (Date.now() >= deadline) return '';
|
||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function api(path, 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}${path}`, {
|
||
method,
|
||
headers: finalHeaders,
|
||
body: finalBody,
|
||
});
|
||
const text = await response.text();
|
||
let json = null;
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch {
|
||
json = null;
|
||
}
|
||
return {
|
||
status: response.status,
|
||
json,
|
||
text,
|
||
data: json?.data,
|
||
error: json?.error,
|
||
headers: response.headers,
|
||
};
|
||
}
|
||
|
||
function stamp() {
|
||
return `${Date.now()}${Math.floor(Math.random() * 100000)}`;
|
||
}
|
||
|
||
/** 每次运行都用新手机号注册,避免复用账号把购买记录的「唯一性」断言做假。 */
|
||
async function registerAccount(prefix) {
|
||
const phone = `${prefix}${String(Date.now() + Math.floor(Math.random() * 1000)).slice(-8)}`;
|
||
const entry = await api('/api/auth/entry', {
|
||
method: 'POST',
|
||
body: { purePhoneNumber: phone, password: 'GenE2e123!' },
|
||
});
|
||
return { status: entry.status, token: entry.data?.token ?? '', phone };
|
||
}
|
||
|
||
async function uploadCover(token, id) {
|
||
const fileName = `cover-${id}.png`;
|
||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||
method: 'POST',
|
||
token,
|
||
body: {
|
||
legacyPrefix: 'generated-character-drafts',
|
||
pathSegments: ['game-distribution', 'cover', 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-cover',
|
||
},
|
||
});
|
||
if (confirm.status !== 200) {
|
||
throw new Error(
|
||
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
return confirm.data.assetObject.assetObjectId;
|
||
}
|
||
|
||
/**
|
||
* 生成最小可玩发行包:入口页里带唯一标记,用来证明播放会话读到的是同一份包内容。
|
||
*/
|
||
async function buildPackage(marker) {
|
||
const entry = `<!doctype html><html><head><meta charset="utf-8"><title>E2E ${marker}</title><script src="assets/app.js"></script></head><body><h1>${marker}</h1></body></html>`;
|
||
const asset = `document.documentElement.dataset.e2e="${marker}";`;
|
||
const zip = new JSZip();
|
||
zip.file('index.html', entry);
|
||
zip.file('assets/app.js', asset);
|
||
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
|
||
const crypto = await import('node:crypto');
|
||
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
||
return {
|
||
bytes,
|
||
sha256,
|
||
entry,
|
||
asset,
|
||
assetPath: 'assets/app.js',
|
||
fileCount: 2,
|
||
marker,
|
||
};
|
||
}
|
||
|
||
function gameMetadata(title, coverAssetId) {
|
||
return {
|
||
title,
|
||
summary: '买断制泥点付费真实链路验证',
|
||
description: 'E2E:定价 → 审核 → 购买 → 播放会话',
|
||
category: '益智',
|
||
tags: ['E2E'],
|
||
deviceSupport: { desktop: true, mobile: true, touch: true },
|
||
inputModes: ['keyboard', 'mouse', 'touch'],
|
||
orientation: 'responsive',
|
||
coverAssetId,
|
||
screenshots: [],
|
||
};
|
||
}
|
||
|
||
/** 建版本(冻结价格)→ 上传发行包 → 送审,返回待审版本与包内容。 */
|
||
async function createVersionAndSubmit({
|
||
authorToken,
|
||
gameId,
|
||
price,
|
||
marker,
|
||
title,
|
||
coverAssetId,
|
||
expectedPublicationRevision,
|
||
}) {
|
||
const id = stamp();
|
||
const metadata = gameMetadata(title, coverAssetId);
|
||
const pkg = await buildPackage(marker);
|
||
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||
method: 'POST',
|
||
token: authorToken,
|
||
headers: { 'Idempotency-Key': `e2e-purchase-version-${id}` },
|
||
body: {
|
||
priceMudPoints: price,
|
||
packageSha256: pkg.sha256,
|
||
packageBytes: pkg.bytes.length,
|
||
packageFileCount: pkg.fileCount,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: metadata,
|
||
},
|
||
});
|
||
if (version.status !== 200 || !version.data?.versionId) {
|
||
throw new Error(
|
||
`创建版本失败 ${version.status} ${version.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
const versionId = version.data.versionId;
|
||
|
||
const upload = await api(
|
||
`/api/game-distribution/versions/${versionId}/package`,
|
||
{
|
||
method: 'PUT',
|
||
token: authorToken,
|
||
headers: {
|
||
'Idempotency-Key': `e2e-purchase-upload-${id}`,
|
||
'Content-Type': 'application/zip',
|
||
},
|
||
binary: pkg.bytes,
|
||
},
|
||
);
|
||
if (upload.status !== 200 || upload.data?.status !== 'uploaded') {
|
||
throw new Error(
|
||
`上传发行包失败 ${upload.status} ${upload.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
|
||
const submitted = await api(
|
||
`/api/game-distribution/versions/${versionId}/submit`,
|
||
{
|
||
method: 'POST',
|
||
token: authorToken,
|
||
headers: { 'Idempotency-Key': `e2e-purchase-submit-${id}` },
|
||
body: { expectedPublicationRevision },
|
||
},
|
||
);
|
||
if (submitted.status !== 202) {
|
||
throw new Error(
|
||
`送审失败 ${submitted.status} ${submitted.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
return { versionId, pkg };
|
||
}
|
||
|
||
/** 走完「建游戏 → 建版本(冻结价格)→ 传包 → 送审 → 管理员通过」的真实发布链路。 */
|
||
async function publishGame({
|
||
authorToken,
|
||
adminToken,
|
||
coverAssetId,
|
||
price,
|
||
marker,
|
||
title,
|
||
}) {
|
||
const id = stamp();
|
||
const metadata = gameMetadata(title, coverAssetId);
|
||
const created = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: authorToken,
|
||
headers: { 'Idempotency-Key': `e2e-purchase-game-${id}` },
|
||
body: metadata,
|
||
});
|
||
if (created.status !== 200 || !created.data?.id) {
|
||
throw new Error(
|
||
`创建游戏失败 ${created.status} ${created.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
const gameId = created.data.id;
|
||
|
||
const { versionId, pkg } = await createVersionAndSubmit({
|
||
authorToken,
|
||
gameId,
|
||
price,
|
||
marker,
|
||
title,
|
||
coverAssetId,
|
||
expectedPublicationRevision: created.data.publicationRevision ?? 0,
|
||
});
|
||
|
||
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
|
||
token: authorToken,
|
||
});
|
||
const approved = await api(
|
||
`/admin/api/game-distribution/versions/${versionId}/review`,
|
||
{
|
||
method: 'POST',
|
||
token: adminToken,
|
||
headers: { 'Idempotency-Key': `e2e-purchase-approve-${id}` },
|
||
body: {
|
||
decision: 'approve',
|
||
expectedPublicationRevision:
|
||
readback.data?.version?.publicationRevision ?? 0,
|
||
},
|
||
},
|
||
);
|
||
if (approved.status !== 200) {
|
||
throw new Error(
|
||
`审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
return { gameId, versionId, pkg };
|
||
}
|
||
|
||
async function walletBalance(token) {
|
||
const dashboard = await api('/api/profile/dashboard', { token });
|
||
return dashboard.data?.walletBalance;
|
||
}
|
||
|
||
async function walletLedgerEntries(token) {
|
||
const ledger = await api('/api/profile/wallet-ledger', { token });
|
||
return ledger.data?.entries ?? [];
|
||
}
|
||
|
||
/** access token 的 JWT payload 里 `sub` 就是 user_id,用于直读购买表。 */
|
||
function jwtSubject(token) {
|
||
try {
|
||
const payload = token.split('.')[1];
|
||
const claims = JSON.parse(
|
||
Buffer.from(payload, 'base64url').toString('utf8'),
|
||
);
|
||
return typeof claims?.sub === 'string' ? claims.sub : '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 只读查询本地 SpacetimeDB(`spacetime sql`),用于交叉验证 HTTP 之外的落库事实。
|
||
*
|
||
* CLI 路径与本地发布 identity 的 config 都来自 `.app/dev-stack.json`;CLI 缺失、未登录或
|
||
* 查询失败时返回 null,由调用方降级为 API 可见证据并打印 WARN,不把环境问题当业务失败。
|
||
*/
|
||
function spacetimeCount(sql) {
|
||
const dataDir = devStack?.spacetimeDataDir;
|
||
const serverUrl = devStack?.services?.spacetime?.url;
|
||
const database = devStack?.database;
|
||
if (!dataDir || !serverUrl || !database) return null;
|
||
const result = spawnSync(
|
||
'spacetime',
|
||
[
|
||
'--config-path',
|
||
resolve(dataDir, 'dev-cli/cli.toml'),
|
||
'sql',
|
||
database,
|
||
sql,
|
||
'--server',
|
||
serverUrl,
|
||
],
|
||
{ encoding: 'utf8', shell: process.platform === 'win32', timeout: 60_000 },
|
||
);
|
||
if (result.error || result.status !== 0) return null;
|
||
const numbers = String(result.stdout ?? '')
|
||
.split(/\r?\n/u)
|
||
.map((line) => line.trim())
|
||
.filter((line) => /^\d+$/u.test(line));
|
||
return numbers.length ? Number(numbers[numbers.length - 1]) : null;
|
||
}
|
||
|
||
/** 购买记录条数:直读 game_distribution_purchase;环境不可用时返回 null。 */
|
||
function purchaseRowCount(userId, gameId) {
|
||
if (!userId) return null;
|
||
return spacetimeCount(
|
||
`SELECT COUNT(*) AS c FROM game_distribution_purchase WHERE user_id = '${userId}' AND game_id = '${gameId}'`,
|
||
);
|
||
}
|
||
|
||
/** 购买流水条数:只数 game_purchase 来源,避免把充值等其他流水算进来。 */
|
||
function gamePurchaseLedgerCount(entries) {
|
||
return entries.filter(
|
||
(entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE,
|
||
).length;
|
||
}
|
||
|
||
/**
|
||
* 平台同源发行入口:只有主站 Vite 在跑时才能真实走 `/games/<gameId>/`。
|
||
*
|
||
* 缺主站地址时不再静默返回 null:显式记录 SKIP 并在报告末尾汇总;设置
|
||
* `E2E_REQUIRE_PLATFORM_ORIGIN=1` 时缺配置直接 FAIL,避免「没验过」被当成通过。
|
||
*/
|
||
async function fetchPlatformEntry(assertionName, gameId, assetPath = '') {
|
||
if (!WEB) {
|
||
if (REQUIRE_PLATFORM_ORIGIN) {
|
||
check(
|
||
assertionName,
|
||
false,
|
||
'E2E_REQUIRE_PLATFORM_ORIGIN=1 但缺少 E2E_WEB_BASE / dev-stack 主站地址',
|
||
);
|
||
} else {
|
||
skip(
|
||
assertionName,
|
||
'未发现主站 Vite(缺少 E2E_WEB_BASE),本轮未验证平台同源路径',
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
const response = await fetch(`${WEB}/games/${gameId}/${assetPath}`);
|
||
const body = await response.text();
|
||
return { status: response.status, body };
|
||
}
|
||
|
||
/**
|
||
* 取一份真实的平台 refresh Cookie(含服务端下发的 Path 属性)。
|
||
*
|
||
* 登录请求通过 `base` 发出:`base` 传主站 Vite 时走的是和浏览器完全相同的 dev 代理链路。
|
||
*/
|
||
async function loginWithRefreshCookie(base) {
|
||
const phone = `136${String(Date.now() + Math.floor(Math.random() * 1000)).slice(-8)}`;
|
||
const response = await fetch(`${base}/api/auth/entry`, {
|
||
method: 'POST',
|
||
headers: { ...ENVELOPE, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ purePhoneNumber: phone, password: 'GenE2e123!' }),
|
||
});
|
||
const text = await response.text();
|
||
let json = null;
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch {
|
||
json = null;
|
||
}
|
||
const setCookies =
|
||
typeof response.headers.getSetCookie === 'function'
|
||
? response.headers.getSetCookie()
|
||
: [];
|
||
return {
|
||
status: response.status,
|
||
token: json?.data?.token ?? '',
|
||
setCookies,
|
||
cookieHeader: setCookies.map((entry) => entry.split(';')[0]).join('; '),
|
||
};
|
||
}
|
||
|
||
/** 平台 refresh Cookie 的 Path 属性决定浏览器会不会把它带到播放会话前缀上。 */
|
||
function refreshCookiePath(setCookie) {
|
||
const matched = /;\s*path=([^;]+)/iu.exec(setCookie);
|
||
return matched ? matched[1].trim() : '';
|
||
}
|
||
|
||
async function waitForStackReady(deadlineMs = 900_000) {
|
||
const startedAt = Date.now();
|
||
while (Date.now() - startedAt < deadlineMs) {
|
||
devStack = readDevStack();
|
||
const apiService = devStack?.services?.['api-server'];
|
||
const candidate = normalizeBase(
|
||
process.env.E2E_API_BASE ?? apiService?.url,
|
||
);
|
||
if (candidate) {
|
||
try {
|
||
const response = await fetch(`${candidate}/healthz`);
|
||
if (response.ok) {
|
||
return candidate;
|
||
}
|
||
} catch {
|
||
// 端口还没起来:继续等。
|
||
}
|
||
}
|
||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));
|
||
}
|
||
throw new Error('等待 API /healthz 就绪超时');
|
||
}
|
||
|
||
let stackChild = null;
|
||
let stackDumpLogs = null;
|
||
|
||
function startStack() {
|
||
const isWindows = process.platform === 'win32';
|
||
stackChild = spawn('npm', ['run', 'dev', '--', '--no-interactive'], {
|
||
cwd: REPO_ROOT,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
shell: isWindows,
|
||
detached: !isWindows,
|
||
env: process.env,
|
||
});
|
||
const logChunks = [];
|
||
const capture = (chunk) => {
|
||
logChunks.push(chunk);
|
||
if (logChunks.length > 400) logChunks.shift();
|
||
};
|
||
stackChild.stdout?.on('data', capture);
|
||
stackChild.stderr?.on('data', capture);
|
||
const dump = () => {
|
||
try {
|
||
writeFileSync(STACK_LOG_PATH, Buffer.concat(logChunks));
|
||
} catch {
|
||
// 日志落盘失败不影响用例结论。
|
||
}
|
||
};
|
||
stackChild.on('exit', dump);
|
||
stackDumpLogs = dump;
|
||
return dump;
|
||
}
|
||
|
||
/** 只收自己拉起的 dev 栈;attach 模式不动别人已经在跑的服务。 */
|
||
function stopStack(dumpLogs) {
|
||
if (!stackChild?.pid) return;
|
||
try {
|
||
dumpLogs?.();
|
||
} catch {
|
||
// 忽略日志落盘失败。
|
||
}
|
||
const isWindows = process.platform === 'win32';
|
||
// dev.mjs 记录的 pid 才是 SpacetimeDB / api-server / bgfilter-worker 的真实进程;
|
||
// 只杀 npm 外壳会在 Windows 上留下孤儿进程,所以两者都要按实例声明回收。
|
||
const recorded = readDevStack();
|
||
const pids = new Set([String(stackChild.pid)]);
|
||
const instanceId = recorded?.instanceId;
|
||
if (instanceId) {
|
||
for (const service of Object.values(recorded?.services ?? {})) {
|
||
if (
|
||
service?.instanceId === instanceId &&
|
||
Number.isInteger(service?.pid)
|
||
) {
|
||
pids.add(String(service.pid));
|
||
}
|
||
}
|
||
}
|
||
if (isWindows) {
|
||
for (const pid of pids) {
|
||
spawnSync('taskkill', ['/PID', pid, '/T', '/F'], { stdio: 'ignore' });
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
process.kill(-stackChild.pid, 'SIGTERM');
|
||
} catch {
|
||
stackChild.kill('SIGTERM');
|
||
}
|
||
for (const pid of pids) {
|
||
if (pid === String(stackChild.pid)) continue;
|
||
try {
|
||
process.kill(Number(pid), 'SIGTERM');
|
||
} catch {
|
||
// 进程已经退出。
|
||
}
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
if (START_STACK) {
|
||
console.log('[e2e] E2E_START_STACK=1:自行拉起本地 dev 栈');
|
||
const dumpLogs = startStack();
|
||
try {
|
||
const resolved = await waitForStackReady();
|
||
devStack = readDevStack();
|
||
API = normalizeBase(process.env.E2E_API_BASE ?? resolved);
|
||
console.log(`[e2e] dev 栈就绪: api-server=${API}`);
|
||
} catch (error) {
|
||
dumpLogs();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
WEB = await resolveWebBase();
|
||
|
||
if (!API) {
|
||
console.error(
|
||
'缺少 api-server 地址:请设置 E2E_API_BASE,或用 E2E_START_STACK=1 由脚本自起 dev 栈。',
|
||
);
|
||
return 2;
|
||
}
|
||
|
||
console.log(`[e2e] api-server: ${API}`);
|
||
if (WEB) {
|
||
console.log(`[e2e] 主站 Vite: ${WEB}`);
|
||
} else {
|
||
warn(
|
||
'未发现主站 Vite,跳过平台同源 /games/<gameId>/ 断言',
|
||
'可设置 E2E_WEB_BASE 或 E2E_START_STACK=1 覆盖',
|
||
);
|
||
}
|
||
|
||
// 1. 账号与管理员
|
||
const author = await registerAccount('137');
|
||
check(
|
||
'作者账号注册拿到 token',
|
||
author.status === 200 && Boolean(author.token),
|
||
`status=${author.status}`,
|
||
);
|
||
const buyer = await registerAccount('138');
|
||
check(
|
||
'买家账号注册拿到 token',
|
||
buyer.status === 200 && Boolean(buyer.token),
|
||
`status=${buyer.status}`,
|
||
);
|
||
const stranger = await registerAccount('139');
|
||
check(
|
||
'未购买账号注册拿到 token',
|
||
stranger.status === 200 && Boolean(stranger.token),
|
||
`status=${stranger.status}`,
|
||
);
|
||
|
||
const adminLogin = await api('/admin/api/login', {
|
||
method: 'POST',
|
||
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
|
||
});
|
||
const adminToken = adminLogin.data?.token ?? adminLogin.data?.accessToken;
|
||
check(
|
||
'管理员登录成功',
|
||
adminLogin.status === 200 && Boolean(adminToken),
|
||
`status=${adminLogin.status}`,
|
||
);
|
||
if (!author.token || !buyer.token || !stranger.token || !adminToken) {
|
||
console.error('[e2e] 缺少必要身份,无法继续');
|
||
return 1;
|
||
}
|
||
|
||
// 发布灰度是写入闸门:显式开到 100%,避免被运营收紧状态误伤。
|
||
const gateOpen = await api('/admin/api/feature-gates', {
|
||
method: 'PUT',
|
||
token: adminToken,
|
||
body: {
|
||
gateKey: 'game-distribution:publish',
|
||
enabled: true,
|
||
rolloutPercent: 100,
|
||
allowUserIds: [],
|
||
allowUserTags: [],
|
||
denyUserIds: [],
|
||
description: 'E2E 买断制付费发布灰度',
|
||
},
|
||
});
|
||
check(
|
||
'发布灰度可开启并放量',
|
||
gateOpen.status === 200,
|
||
`status=${gateOpen.status}`,
|
||
);
|
||
|
||
const coverAssetId = await uploadCover(author.token, stamp());
|
||
check(
|
||
'作者封面素材直传并 confirm',
|
||
Boolean(coverAssetId),
|
||
String(coverAssetId).slice(0, 24),
|
||
);
|
||
|
||
// 2. 付费游戏:发布 → 审核通过
|
||
const paidMarker = `E2E-PAID-OK-${stamp()}`;
|
||
const paid = await publishGame({
|
||
authorToken: author.token,
|
||
adminToken,
|
||
coverAssetId,
|
||
price: PRICE_MUD_POINTS,
|
||
marker: paidMarker,
|
||
title: `买断制支付验证 ${stamp().slice(-6)}`,
|
||
});
|
||
check(
|
||
'付费游戏发布并审核通过',
|
||
Boolean(paid.gameId && paid.versionId),
|
||
`gameId=${paid.gameId}`,
|
||
);
|
||
|
||
// 3. 未购买用户:看得到资料与价格,但拿不到任何可播放入口
|
||
const anonymousDetail = await api(
|
||
`/api/game-distribution/games/${paid.gameId}`,
|
||
);
|
||
check(
|
||
'匿名公开详情展示价格',
|
||
anonymousDetail.status === 200 &&
|
||
anonymousDetail.data?.priceMudPoints === PRICE_MUD_POINTS,
|
||
`status=${anonymousDetail.status} price=${anonymousDetail.data?.priceMudPoints}`,
|
||
);
|
||
check(
|
||
'匿名公开详情 purchased=false 且无发行入口',
|
||
anonymousDetail.data?.purchased === false &&
|
||
anonymousDetail.data?.currentVersion?.entryUrl === null,
|
||
`purchased=${anonymousDetail.data?.purchased} entryUrl=${String(anonymousDetail.data?.currentVersion?.entryUrl)}`,
|
||
);
|
||
|
||
const strangerDetail = await api(
|
||
`/api/game-distribution/games/${paid.gameId}`,
|
||
{
|
||
token: stranger.token,
|
||
},
|
||
);
|
||
check(
|
||
'未购买账号详情 purchased=false 且无发行入口',
|
||
strangerDetail.status === 200 &&
|
||
strangerDetail.data?.purchased === false &&
|
||
strangerDetail.data?.currentVersion?.entryUrl === null,
|
||
`status=${strangerDetail.status} purchased=${strangerDetail.data?.purchased} entryUrl=${String(strangerDetail.data?.currentVersion?.entryUrl)}`,
|
||
);
|
||
|
||
const paidGateway = await fetch(
|
||
`${API}/api/game-distribution/releases/${paid.gameId}`,
|
||
);
|
||
const paidGatewayBody = await paidGateway.text();
|
||
check(
|
||
'付费作品直连发行网关入口 404',
|
||
paidGateway.status === 404,
|
||
`status=${paidGateway.status} bytes=${paidGatewayBody.length}`,
|
||
);
|
||
const paidGatewayAsset = await fetch(
|
||
`${API}/api/game-distribution/releases/${paid.gameId}/${paid.pkg.assetPath}`,
|
||
);
|
||
check(
|
||
'付费作品直连发行网关包内资源 404',
|
||
paidGatewayAsset.status === 404,
|
||
`status=${paidGatewayAsset.status}`,
|
||
);
|
||
|
||
const paidPlatformEntry = await fetchPlatformEntry(
|
||
'付费作品平台同源 /games/<gameId>/ 404',
|
||
paid.gameId,
|
||
);
|
||
if (paidPlatformEntry) {
|
||
check(
|
||
'付费作品平台同源 /games/<gameId>/ 404',
|
||
paidPlatformEntry.status === 404,
|
||
`status=${paidPlatformEntry.status}`,
|
||
);
|
||
}
|
||
|
||
const strangerSession = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/play-session`,
|
||
{ method: 'POST', token: stranger.token },
|
||
);
|
||
check(
|
||
'未购买账号请求播放会话 403',
|
||
strangerSession.status === 403,
|
||
`status=${strangerSession.status} code=${strangerSession.error?.code ?? ''}`,
|
||
);
|
||
const anonymousSession = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/play-session`,
|
||
{ method: 'POST' },
|
||
);
|
||
check(
|
||
'未登录请求付费播放会话 401',
|
||
anonymousSession.status === 401,
|
||
`status=${anonymousSession.status}`,
|
||
);
|
||
|
||
// 4. 购买:余额、账单与幂等
|
||
const buyerBefore = await walletBalance(buyer.token);
|
||
check(
|
||
'买家钱包有足够余额完成购买',
|
||
Number.isFinite(buyerBefore) && buyerBefore >= PRICE_MUD_POINTS,
|
||
`balance=${buyerBefore} price=${PRICE_MUD_POINTS}`,
|
||
);
|
||
|
||
const purchaseKey = `e2e-purchase-${stamp()}`;
|
||
const purchase = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/purchase`,
|
||
{
|
||
method: 'POST',
|
||
token: buyer.token,
|
||
headers: { 'Idempotency-Key': purchaseKey },
|
||
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
|
||
},
|
||
);
|
||
check(
|
||
'购买成功并扣费',
|
||
purchase.status === 200 &&
|
||
purchase.data?.purchase?.priceMudPoints === PRICE_MUD_POINTS &&
|
||
purchase.data?.replayed === false &&
|
||
purchase.data?.walletBalance === buyerBefore - PRICE_MUD_POINTS,
|
||
`status=${purchase.status} replayed=${purchase.data?.replayed} balance=${purchase.data?.walletBalance} before=${buyerBefore}`,
|
||
);
|
||
const afterPurchaseBalance = purchase.data?.walletBalance;
|
||
const purchaseId = purchase.data?.purchase?.purchaseId;
|
||
|
||
const ledgerAfterPurchase = await walletLedgerEntries(buyer.token);
|
||
const purchaseLedger = ledgerAfterPurchase.filter(
|
||
(entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE,
|
||
);
|
||
check(
|
||
'账单出现唯一的 game_purchase 扣费流水',
|
||
purchaseLedger.length === 1 &&
|
||
purchaseLedger[0]?.amountDelta === -PRICE_MUD_POINTS &&
|
||
purchaseLedger[0]?.balanceAfter === afterPurchaseBalance,
|
||
`count=${purchaseLedger.length} amountDelta=${purchaseLedger[0]?.amountDelta} balanceAfter=${purchaseLedger[0]?.balanceAfter}`,
|
||
);
|
||
|
||
// 同 key 重放:必须回放既有购买,不再扣费
|
||
const replay = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/purchase`,
|
||
{
|
||
method: 'POST',
|
||
token: buyer.token,
|
||
headers: { 'Idempotency-Key': purchaseKey },
|
||
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
|
||
},
|
||
);
|
||
check(
|
||
'同 Idempotency-Key 重放只扣一次',
|
||
replay.status === 200 &&
|
||
replay.data?.replayed === true &&
|
||
replay.data?.walletBalance === afterPurchaseBalance &&
|
||
replay.data?.purchase?.purchaseId === purchaseId,
|
||
`status=${replay.status} replayed=${replay.data?.replayed} balance=${replay.data?.walletBalance}`,
|
||
);
|
||
|
||
// 换 key 重复购买:已有所有权,同样不再扣费
|
||
const duplicate = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/purchase`,
|
||
{
|
||
method: 'POST',
|
||
token: buyer.token,
|
||
headers: { 'Idempotency-Key': `e2e-purchase-${stamp()}` },
|
||
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
|
||
},
|
||
);
|
||
check(
|
||
'换 key 重复购买只扣一次',
|
||
duplicate.status === 200 &&
|
||
duplicate.data?.replayed === true &&
|
||
duplicate.data?.walletBalance === afterPurchaseBalance,
|
||
`status=${duplicate.status} replayed=${duplicate.data?.replayed} balance=${duplicate.data?.walletBalance}`,
|
||
);
|
||
|
||
const ledgerAfterDuplicate = await walletLedgerEntries(buyer.token);
|
||
check(
|
||
'重复购买未新增 game_purchase 流水',
|
||
ledgerAfterDuplicate.filter(
|
||
(entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE,
|
||
).length === 1,
|
||
`count=${ledgerAfterDuplicate.filter((entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE).length}`,
|
||
);
|
||
|
||
const buyerDetail = await api(`/api/game-distribution/games/${paid.gameId}`, {
|
||
token: buyer.token,
|
||
});
|
||
check(
|
||
'购买后详情 purchased=true 且下发发行入口',
|
||
buyerDetail.data?.purchased === true &&
|
||
buyerDetail.data?.currentVersion?.entryUrl === `/games/${paid.gameId}/`,
|
||
`purchased=${buyerDetail.data?.purchased} entryUrl=${String(buyerDetail.data?.currentVersion?.entryUrl)}`,
|
||
);
|
||
|
||
// 5. 播放会话:签发令牌、可玩、重复进入不再扣费
|
||
const playSession = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/play-session`,
|
||
{ method: 'POST', token: buyer.token },
|
||
);
|
||
const playUrl = playSession.data?.playUrl ?? '';
|
||
check(
|
||
'购买后签发播放会话 playUrl',
|
||
playSession.status === 200 &&
|
||
playUrl.startsWith('/api/game-distribution/play-sessions/'),
|
||
`status=${playSession.status} playUrl=${playUrl.slice(0, 48)}…`,
|
||
);
|
||
|
||
const sessionEntry = await fetch(`${API}${playUrl}`);
|
||
const sessionEntryBody = await sessionEntry.text();
|
||
check(
|
||
'播放会话入口 200 且是同一发行包内容',
|
||
sessionEntry.status === 200 &&
|
||
sessionEntryBody.includes(paid.pkg.marker) &&
|
||
/<html|<!doctype html/iu.test(sessionEntryBody),
|
||
`status=${sessionEntry.status} marker=${sessionEntryBody.includes(paid.pkg.marker)}`,
|
||
);
|
||
check(
|
||
'播放会话入口带 nosniff',
|
||
sessionEntry.headers.get('x-content-type-options') === 'nosniff',
|
||
String(sessionEntry.headers.get('x-content-type-options')),
|
||
);
|
||
|
||
const sessionAsset = await fetch(`${API}${playUrl}${paid.pkg.assetPath}`);
|
||
const sessionAssetBody = await sessionAsset.text();
|
||
check(
|
||
'播放会话包内资源与上传内容逐字节一致',
|
||
sessionAsset.status === 200 && sessionAssetBody === paid.pkg.asset,
|
||
`status=${sessionAsset.status} bytes=${sessionAssetBody.length}`,
|
||
);
|
||
|
||
const secondSession = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/play-session`,
|
||
{ method: 'POST', token: buyer.token },
|
||
);
|
||
const balanceAfterSecondEntry = await walletBalance(buyer.token);
|
||
check(
|
||
'重复进入不再扣费',
|
||
secondSession.status === 200 &&
|
||
balanceAfterSecondEntry === afterPurchaseBalance,
|
||
`status=${secondSession.status} balance=${balanceAfterSecondEntry} expected=${afterPurchaseBalance}`,
|
||
);
|
||
|
||
// 6. 播放会话网关的来源约束与伪造令牌
|
||
// (1) 直连 api-server 且不带 Cookie:上面已断言 200(这是播放会话的正常读取姿态)。
|
||
// (2) 伪造令牌:拿不到 token 的未购买账号即使猜 URL 也只能拿到 404。
|
||
const forgedToken = `forged${stamp()}`;
|
||
const forgedEntry = await fetch(
|
||
`${API}/api/game-distribution/play-sessions/${forgedToken}/`,
|
||
);
|
||
check(
|
||
'伪造播放会话令牌请求入口 404',
|
||
forgedEntry.status === 404,
|
||
`status=${forgedEntry.status}`,
|
||
);
|
||
const forgedAsset = await fetch(
|
||
`${API}/api/game-distribution/play-sessions/${forgedToken}/${paid.pkg.assetPath}`,
|
||
);
|
||
check(
|
||
'伪造播放会话令牌请求包内资源 404',
|
||
forgedAsset.status === 404,
|
||
`status=${forgedAsset.status}`,
|
||
);
|
||
|
||
// (3) 带平台 refresh Cookie:网关按「播放会话必须在独立来源读取」的约束直接 403。
|
||
// 浏览器侧是否真的会带上这个 Cookie 取决于服务端下发的 Path,因此一起取证。
|
||
const cookieSource = WEB || API;
|
||
const cookieLogin = await loginWithRefreshCookie(cookieSource);
|
||
const refreshSetCookie = cookieLogin.setCookies.find((entry) =>
|
||
/refresh|session/iu.test(entry),
|
||
);
|
||
const cookiePath = refreshSetCookie
|
||
? refreshCookiePath(refreshSetCookie)
|
||
: '';
|
||
check(
|
||
'取到平台 refresh Cookie 且 Path 限定非 /api/game-distribution',
|
||
cookieLogin.status === 200 &&
|
||
Boolean(cookieLogin.cookieHeader) &&
|
||
Boolean(cookiePath) &&
|
||
!cookiePath.startsWith('/api/game-distribution'),
|
||
`path=${cookiePath} cookies=${cookieLogin.setCookies.length} via=${cookieSource}`,
|
||
);
|
||
|
||
const cookieInspect = await fetch(`${API}/_internal/auth/refresh-cookie`, {
|
||
headers: { ...ENVELOPE, Cookie: cookieLogin.cookieHeader },
|
||
});
|
||
const cookieInspectBody = await cookieInspect.json().catch(() => null);
|
||
const cookiePresent =
|
||
cookieInspectBody?.data?.present ?? cookieInspectBody?.present;
|
||
check(
|
||
'网关能解析这份 Cookie(确认是有效平台会话)',
|
||
cookieInspect.status === 200 && cookiePresent === true,
|
||
`status=${cookieInspect.status} present=${cookiePresent}`,
|
||
);
|
||
|
||
const cookieEntryDirect = await fetch(`${API}${playUrl}`, {
|
||
headers: { Cookie: cookieLogin.cookieHeader },
|
||
});
|
||
const cookieEntryDirectBody = await cookieEntryDirect.text();
|
||
check(
|
||
'带平台 Cookie 直连播放会话入口被 403 拒绝(网关来源约束)',
|
||
cookieEntryDirect.status === 403,
|
||
`status=${cookieEntryDirect.status} bytes=${cookieEntryDirectBody.length}`,
|
||
);
|
||
if (WEB) {
|
||
// dev 代理对播放会话前缀有专门规则清空 Cookie(vite.config.ts 里排在通用
|
||
// `/api/game-distribution` 之前;生产 nginx 用 `location ^~ .../play-sessions/` 做同一件事),
|
||
// 所以带 Cookie 经代理访问仍然可玩。这就是 iframe 在真实浏览器里可用的边缘证据。
|
||
const cookieEntryViaProxy = await fetch(`${WEB}${playUrl}`, {
|
||
headers: { Cookie: cookieLogin.cookieHeader },
|
||
});
|
||
const cookieEntryViaProxyBody = await cookieEntryViaProxy.text();
|
||
check(
|
||
'带平台 Cookie 经 dev 代理仍能播放(代理按前缀清空 Cookie)',
|
||
cookieEntryViaProxy.status === 200 &&
|
||
cookieEntryViaProxyBody.includes(paid.pkg.marker),
|
||
`status=${cookieEntryViaProxy.status} marker=${cookieEntryViaProxyBody.includes(paid.pkg.marker)} bytes=${cookieEntryViaProxyBody.length}`,
|
||
);
|
||
const forgedViaProxy = await fetch(
|
||
`${WEB}/api/game-distribution/play-sessions/${forgedToken}/`,
|
||
{ headers: { Cookie: cookieLogin.cookieHeader } },
|
||
);
|
||
check(
|
||
'伪造令牌经 dev 代理请求播放会话入口 404',
|
||
forgedViaProxy.status === 404,
|
||
`status=${forgedViaProxy.status}`,
|
||
);
|
||
} else {
|
||
warn('跳过 dev 代理带 Cookie 取证', '未发现主站 Vite');
|
||
}
|
||
|
||
// 7. 免费游戏回归:公开入口直接可玩,播放会话不签发令牌
|
||
const freeMarker = `E2E-FREE-OK-${stamp()}`;
|
||
const free = await publishGame({
|
||
authorToken: author.token,
|
||
adminToken,
|
||
coverAssetId,
|
||
price: 0,
|
||
marker: freeMarker,
|
||
title: `免费游戏回归 ${stamp().slice(-6)}`,
|
||
});
|
||
check(
|
||
'免费游戏发布并审核通过',
|
||
Boolean(free.gameId && free.versionId),
|
||
`gameId=${free.gameId}`,
|
||
);
|
||
|
||
const freeDetail = await api(`/api/game-distribution/games/${free.gameId}`);
|
||
check(
|
||
'免费游戏公开详情保留发行入口',
|
||
freeDetail.status === 200 &&
|
||
freeDetail.data?.priceMudPoints === 0 &&
|
||
freeDetail.data?.currentVersion?.entryUrl === `/games/${free.gameId}/`,
|
||
`status=${freeDetail.status} price=${freeDetail.data?.priceMudPoints} entryUrl=${String(freeDetail.data?.currentVersion?.entryUrl)}`,
|
||
);
|
||
|
||
const freeGateway = await fetch(
|
||
`${API}/api/game-distribution/releases/${free.gameId}/`,
|
||
);
|
||
const freeGatewayBody = await freeGateway.text();
|
||
check(
|
||
'免费游戏发行网关直接可玩',
|
||
freeGateway.status === 200 && freeGatewayBody.includes(freeMarker),
|
||
`status=${freeGateway.status} marker=${freeGatewayBody.includes(freeMarker)}`,
|
||
);
|
||
|
||
const freePlatformEntry = await fetchPlatformEntry(
|
||
'免费游戏平台同源 /games/<gameId>/ 直接可玩',
|
||
free.gameId,
|
||
);
|
||
if (freePlatformEntry) {
|
||
check(
|
||
'免费游戏平台同源 /games/<gameId>/ 直接可玩',
|
||
freePlatformEntry.status === 200 &&
|
||
freePlatformEntry.body.includes(freeMarker),
|
||
`status=${freePlatformEntry.status} marker=${freePlatformEntry.body.includes(freeMarker)}`,
|
||
);
|
||
}
|
||
|
||
const freeSession = await api(
|
||
`/api/game-distribution/games/${free.gameId}/play-session`,
|
||
{ method: 'POST' },
|
||
);
|
||
check(
|
||
'免费游戏播放会话不签发令牌、直接回公开入口',
|
||
freeSession.status === 200 &&
|
||
freeSession.data?.playUrl === `/games/${free.gameId}/`,
|
||
`status=${freeSession.status} playUrl=${String(freeSession.data?.playUrl)}`,
|
||
);
|
||
|
||
// 8. 附加:余额不足时购买失败且不产生任何副作用
|
||
if (Number.isFinite(buyerBefore) && buyerBefore < 1_000_000) {
|
||
const priceyMarker = `E2E-PRICEY-OK-${stamp()}`;
|
||
const pricey = await publishGame({
|
||
authorToken: author.token,
|
||
adminToken,
|
||
coverAssetId,
|
||
price: 1_000_000,
|
||
marker: priceyMarker,
|
||
title: `余额不足验证 ${stamp().slice(-6)}`,
|
||
});
|
||
const balanceBeforeFailure = await walletBalance(buyer.token);
|
||
const ledgerBeforeFailure = (await walletLedgerEntries(buyer.token)).length;
|
||
const insufficient = await api(
|
||
`/api/game-distribution/games/${pricey.gameId}/purchase`,
|
||
{
|
||
method: 'POST',
|
||
token: buyer.token,
|
||
headers: { 'Idempotency-Key': `e2e-purchase-insufficient-${stamp()}` },
|
||
body: { expectedPriceMudPoints: 1_000_000 },
|
||
},
|
||
);
|
||
const balanceAfterFailure = await walletBalance(buyer.token);
|
||
const ledgerAfterFailure = (await walletLedgerEntries(buyer.token)).length;
|
||
check(
|
||
'余额不足购买失败(400 INSUFFICIENT_MUD_POINTS)',
|
||
insufficient.status === 400 &&
|
||
(insufficient.error?.code === 'INSUFFICIENT_MUD_POINTS' ||
|
||
String(insufficient.error?.message ?? '').includes('泥点')),
|
||
`status=${insufficient.status} code=${insufficient.error?.code ?? ''} msg=${insufficient.error?.message ?? ''}`,
|
||
);
|
||
check(
|
||
'余额不足时余额、账单与购买记录都不变',
|
||
balanceAfterFailure === balanceBeforeFailure &&
|
||
ledgerAfterFailure === ledgerBeforeFailure,
|
||
`balance=${balanceAfterFailure}/${balanceBeforeFailure} ledger=${ledgerAfterFailure}/${ledgerBeforeFailure}`,
|
||
);
|
||
} else {
|
||
warn(
|
||
'跳过余额不足用例',
|
||
`买家余额 ${buyerBefore} 已达上限价,无法构造不足场景`,
|
||
);
|
||
}
|
||
|
||
// 9. 作者本人免购买(作者对自己的付费作品有免购买权,且绝不扣费)
|
||
const authorDetail = await api(
|
||
`/api/game-distribution/games/${paid.gameId}`,
|
||
{
|
||
token: author.token,
|
||
},
|
||
);
|
||
check(
|
||
'作者读取自己付费作品详情:有发行入口但 purchased=false',
|
||
authorDetail.status === 200 &&
|
||
authorDetail.data?.purchased === false &&
|
||
authorDetail.data?.currentVersion?.entryUrl === `/games/${paid.gameId}/`,
|
||
`status=${authorDetail.status} purchased=${authorDetail.data?.purchased} entryUrl=${String(authorDetail.data?.currentVersion?.entryUrl)}`,
|
||
);
|
||
|
||
const authorBalanceBefore = await walletBalance(author.token);
|
||
const authorLedgerBefore = gamePurchaseLedgerCount(
|
||
await walletLedgerEntries(author.token),
|
||
);
|
||
const authorPurchase = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/purchase`,
|
||
{
|
||
method: 'POST',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': `e2e-owner-exempt-${stamp()}` },
|
||
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
|
||
},
|
||
);
|
||
check(
|
||
'作者本人购买自己的付费作品被拒(400 GAME_PURCHASE_OWNER_EXEMPT)',
|
||
authorPurchase.status === 400 &&
|
||
authorPurchase.error?.code === 'GAME_PURCHASE_OWNER_EXEMPT',
|
||
`status=${authorPurchase.status} code=${authorPurchase.error?.code ?? ''} msg=${authorPurchase.error?.message ?? ''}`,
|
||
);
|
||
const authorBalanceAfter = await walletBalance(author.token);
|
||
const authorLedgerAfter = gamePurchaseLedgerCount(
|
||
await walletLedgerEntries(author.token),
|
||
);
|
||
const authorRows = purchaseRowCount(jwtSubject(author.token), paid.gameId);
|
||
check(
|
||
'作者被拒后余额与 game_purchase 流水不变',
|
||
authorBalanceAfter === authorBalanceBefore &&
|
||
authorLedgerAfter === authorLedgerBefore,
|
||
`balance=${authorBalanceAfter}/${authorBalanceBefore} ledger=${authorLedgerAfter}/${authorLedgerBefore}`,
|
||
);
|
||
if (authorRows === null) {
|
||
warn('跳过作者购买记录条数直查', 'spacetime sql 不可用');
|
||
} else {
|
||
check('作者自购不落购买记录', authorRows === 0, `rows=${authorRows}`);
|
||
}
|
||
|
||
const authorSession = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/play-session`,
|
||
{ method: 'POST', token: author.token },
|
||
);
|
||
check(
|
||
'作者本人可直接创建播放会话(免购买)',
|
||
authorSession.status === 200 &&
|
||
String(authorSession.data?.playUrl ?? '').startsWith(
|
||
'/api/game-distribution/play-sessions/',
|
||
),
|
||
`status=${authorSession.status} playUrl=${String(authorSession.data?.playUrl)}`,
|
||
);
|
||
if (authorSession.data?.playUrl) {
|
||
const authorEntry = await fetch(`${API}${authorSession.data.playUrl}`);
|
||
const authorEntryBody = await authorEntry.text();
|
||
check(
|
||
'作者播放会话入口 200 且是同一发行包内容',
|
||
authorEntry.status === 200 && authorEntryBody.includes(paid.pkg.marker),
|
||
`status=${authorEntry.status} marker=${authorEntryBody.includes(paid.pkg.marker)}`,
|
||
);
|
||
}
|
||
|
||
// 10. 管理员免购买(管理员令牌可直接试玩;购买路径必须先被用户鉴权拦下)
|
||
const adminSubject = jwtSubject(adminToken);
|
||
const adminBalanceRowsBefore = purchaseRowCount(adminSubject, paid.gameId);
|
||
const adminSession = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/play-session`,
|
||
{ method: 'POST', token: adminToken },
|
||
);
|
||
check(
|
||
'管理员令牌可直接创建播放会话(免购买)',
|
||
adminSession.status === 200 &&
|
||
String(adminSession.data?.playUrl ?? '').startsWith(
|
||
'/api/game-distribution/play-sessions/',
|
||
),
|
||
`status=${adminSession.status} playUrl=${String(adminSession.data?.playUrl)}`,
|
||
);
|
||
if (adminSession.data?.playUrl) {
|
||
const adminEntry = await fetch(`${API}${adminSession.data.playUrl}`);
|
||
const adminEntryBody = await adminEntry.text();
|
||
check(
|
||
'管理员播放会话入口 200 且是同一发行包内容',
|
||
adminEntry.status === 200 && adminEntryBody.includes(paid.pkg.marker),
|
||
`status=${adminEntry.status} marker=${adminEntryBody.includes(paid.pkg.marker)}`,
|
||
);
|
||
}
|
||
|
||
const adminPurchase = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/purchase`,
|
||
{
|
||
method: 'POST',
|
||
token: adminToken,
|
||
headers: { 'Idempotency-Key': `e2e-admin-purchase-${stamp()}` },
|
||
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
|
||
},
|
||
);
|
||
check(
|
||
'管理员令牌发起购买被拒绝且不产生扣费',
|
||
adminPurchase.status === 401 || adminPurchase.status === 403,
|
||
`status=${adminPurchase.status} code=${adminPurchase.error?.code ?? ''} msg=${adminPurchase.error?.message ?? ''}`,
|
||
);
|
||
if (adminPurchase.status === 403) {
|
||
check(
|
||
'管理员购买被拒码为 GAME_PURCHASE_ADMIN_NOT_ALLOWED',
|
||
adminPurchase.error?.code === 'GAME_PURCHASE_ADMIN_NOT_ALLOWED',
|
||
`code=${adminPurchase.error?.code ?? ''}`,
|
||
);
|
||
} else {
|
||
warn(
|
||
'管理员购买走的是用户鉴权前置 401,未进入 403 GAME_PURCHASE_ADMIN_NOT_ALLOWED 分支',
|
||
'该 403 分支要求带 admin 角色的用户 access token,而现役登录链路只签发 roles=["user"];' +
|
||
'后台管理员令牌在 /api/* 用户路由上先被 require_bearer_auth 判为无效登录态',
|
||
);
|
||
}
|
||
const adminRowsAfter = purchaseRowCount(adminSubject, paid.gameId);
|
||
if (adminRowsAfter === null || adminBalanceRowsBefore === null) {
|
||
warn('跳过管理员购买记录条数直查', 'spacetime sql 不可用');
|
||
} else {
|
||
check(
|
||
'管理员购买被拒后不落购买记录',
|
||
adminRowsAfter === adminBalanceRowsBefore,
|
||
`rows=${adminRowsAfter}/${adminBalanceRowsBefore}`,
|
||
);
|
||
}
|
||
|
||
// 11. 真并发购买:同一未购买账号同时对同一付费游戏发两个不同幂等键
|
||
const strangerBalanceBefore = await walletBalance(stranger.token);
|
||
const [concurrent1, concurrent2] = await Promise.all([
|
||
api(`/api/game-distribution/games/${paid.gameId}/purchase`, {
|
||
method: 'POST',
|
||
token: stranger.token,
|
||
headers: { 'Idempotency-Key': `e2e-concurrent-a-${stamp()}` },
|
||
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
|
||
}),
|
||
api(`/api/game-distribution/games/${paid.gameId}/purchase`, {
|
||
method: 'POST',
|
||
token: stranger.token,
|
||
headers: { 'Idempotency-Key': `e2e-concurrent-b-${stamp()}` },
|
||
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
|
||
}),
|
||
]);
|
||
const concurrentStatuses = [concurrent1.status, concurrent2.status];
|
||
check(
|
||
'真并发购买:两个响应都不是 5xx',
|
||
concurrentStatuses.every((status) => status > 0 && status < 500),
|
||
`statuses=${concurrentStatuses.join('/')} codes=${[concurrent1.error?.code, concurrent2.error?.code].join('/')}`,
|
||
);
|
||
check(
|
||
'真并发购买:状态码为 200 且最多一个 409(允许服务端竞态语义)',
|
||
concurrentStatuses.every((status) => status === 200 || status === 409) &&
|
||
concurrentStatuses.includes(200),
|
||
`statuses=${concurrentStatuses.join('/')}`,
|
||
);
|
||
const chargedClaims = [concurrent1, concurrent2].filter(
|
||
(response) => response.status === 200 && response.data?.replayed === false,
|
||
).length;
|
||
const replayFlags = [concurrent1, concurrent2].map(
|
||
(response) => `${response.status}:${String(response.data?.replayed)}`,
|
||
);
|
||
check(
|
||
'真并发购买:最多一个响应声称发生新扣费(两个都声称即双扣风险)',
|
||
chargedClaims <= 1,
|
||
`chargedClaims=${chargedClaims} replayFlags=${replayFlags.join(',')}`,
|
||
);
|
||
|
||
const strangerBalanceAfter = await walletBalance(stranger.token);
|
||
check(
|
||
'真并发购买:钱包只减少一次',
|
||
strangerBalanceAfter === strangerBalanceBefore - PRICE_MUD_POINTS,
|
||
`balance=${strangerBalanceAfter}/${strangerBalanceBefore} price=${PRICE_MUD_POINTS}`,
|
||
);
|
||
const strangerLedger = gamePurchaseLedgerCount(
|
||
await walletLedgerEntries(stranger.token),
|
||
);
|
||
check(
|
||
'真并发购买:只落 1 条 game_purchase 流水',
|
||
strangerLedger === 1,
|
||
`count=${strangerLedger}`,
|
||
);
|
||
const strangerRows = purchaseRowCount(
|
||
jwtSubject(stranger.token),
|
||
paid.gameId,
|
||
);
|
||
if (strangerRows === null) {
|
||
warn('跳过并发购买记录条数直查', 'spacetime sql 不可用');
|
||
} else {
|
||
check(
|
||
'真并发购买:只落 1 条购买记录',
|
||
strangerRows === 1,
|
||
`rows=${strangerRows}`,
|
||
);
|
||
}
|
||
const strangerDetailAfterPurchase = await api(
|
||
`/api/game-distribution/games/${paid.gameId}`,
|
||
{ token: stranger.token },
|
||
);
|
||
check(
|
||
'真并发购买后详情 purchased=true',
|
||
strangerDetailAfterPurchase.data?.purchased === true,
|
||
`purchased=${strangerDetailAfterPurchase.data?.purchased}`,
|
||
);
|
||
|
||
// 12. 审核员试玩待审付费版本(不限次、不改钱包与购买记录)
|
||
const pendingMarker = `E2E-PENDING-OK-${stamp()}`;
|
||
const paidPublicBeforePending = await api(
|
||
`/api/game-distribution/games/${paid.gameId}`,
|
||
);
|
||
const pending = await createVersionAndSubmit({
|
||
authorToken: author.token,
|
||
gameId: paid.gameId,
|
||
price: PRICE_MUD_POINTS,
|
||
marker: pendingMarker,
|
||
title: `待审付费版本 ${stamp().slice(-6)}`,
|
||
coverAssetId,
|
||
expectedPublicationRevision:
|
||
paidPublicBeforePending.data?.publicationRevision ?? 0,
|
||
});
|
||
const pendingReadback = await api(
|
||
`/api/game-distribution/versions/${pending.versionId}`,
|
||
{ token: author.token },
|
||
);
|
||
check(
|
||
'付费作品可提交待审新版本(pending_review)',
|
||
pendingReadback.status === 200 &&
|
||
pendingReadback.data?.version?.status === 'pending_review',
|
||
`status=${pendingReadback.status} versionStatus=${pendingReadback.data?.version?.status ?? ''}`,
|
||
);
|
||
|
||
const authorLedgerBeforePreview = gamePurchaseLedgerCount(
|
||
await walletLedgerEntries(author.token),
|
||
);
|
||
const previewChecks = [];
|
||
for (let round = 1; round <= 2; round += 1) {
|
||
const preview = await api(
|
||
`/admin/api/game-distribution/versions/${pending.versionId}/preview-session`,
|
||
{ method: 'POST', token: adminToken },
|
||
);
|
||
const previewUrl = preview.data?.previewUrl ?? '';
|
||
const previewEntry = previewUrl
|
||
? await fetch(`${API}${previewUrl}`)
|
||
: { status: 0, text: async () => '' };
|
||
const previewBody = await previewEntry.text();
|
||
const previewAsset = previewUrl
|
||
? await fetch(`${API}${previewUrl}${pending.pkg.assetPath}`)
|
||
: { status: 0, text: async () => '' };
|
||
const previewAssetBody = await previewAsset.text();
|
||
previewChecks.push(
|
||
preview.status === 200 &&
|
||
previewUrl.startsWith('/api/game-distribution/admin-previews/') &&
|
||
previewEntry.status === 200 &&
|
||
previewBody.includes(pendingMarker) &&
|
||
previewAsset.status === 200 &&
|
||
previewAssetBody === pending.pkg.asset,
|
||
);
|
||
check(
|
||
`审核员第 ${round} 次试玩待审付费版本:入口与包内资源都可用且同包`,
|
||
previewChecks[round - 1],
|
||
`previewStatus=${preview.status} entryStatus=${previewEntry.status} marker=${previewBody.includes(pendingMarker)} assetBytes=${previewAssetBody.length}`,
|
||
);
|
||
}
|
||
check(
|
||
'同一待审版本可反复创建试玩会话(不限次)',
|
||
previewChecks.length === 2 && previewChecks.every(Boolean),
|
||
`rounds=${previewChecks.join(',')}`,
|
||
);
|
||
const authorBalanceAfterPreview = await walletBalance(author.token);
|
||
const authorLedgerAfterPreview = gamePurchaseLedgerCount(
|
||
await walletLedgerEntries(author.token),
|
||
);
|
||
const adminRowsAfterPreview = purchaseRowCount(adminSubject, paid.gameId);
|
||
check(
|
||
'试玩待审付费版本不改动钱包与 game_purchase 流水',
|
||
authorBalanceAfterPreview === authorBalanceBefore &&
|
||
authorLedgerAfterPreview === authorLedgerBeforePreview,
|
||
`authorBalance=${authorBalanceAfterPreview}/${authorBalanceBefore} ledger=${authorLedgerAfterPreview}/${authorLedgerBeforePreview}`,
|
||
);
|
||
if (adminRowsAfterPreview === null || adminRowsAfter === null) {
|
||
warn('跳过试玩购买记录条数直查', 'spacetime sql 不可用');
|
||
} else {
|
||
check(
|
||
'审核员试玩不落购买记录',
|
||
adminRowsAfterPreview === adminRowsAfter,
|
||
`rows=${adminRowsAfterPreview}/${adminRowsAfter}`,
|
||
);
|
||
}
|
||
|
||
// 13. 定价越界:1000001 被拒且不落版本;0 与 1000000 边界可通过
|
||
// 定价边界用例用独立作者账号:作者自有列表按条数上限聚合返回版本,作品多的账号
|
||
// 会让目标游戏的版本查不到(既有口径),独立账号才能用公开接口可靠数版本。
|
||
const boundsAuthor = await registerAccount('132');
|
||
const boundsCoverAssetId = await uploadCover(boundsAuthor.token, stamp());
|
||
check(
|
||
'定价边界用例的准备作者与封面就绪',
|
||
boundsAuthor.status === 200 && Boolean(boundsCoverAssetId),
|
||
`status=${boundsAuthor.status} cover=${Boolean(boundsCoverAssetId)}`,
|
||
);
|
||
const boundsGameCreated = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: boundsAuthor.token,
|
||
headers: { 'Idempotency-Key': `e2e-bounds-game-${stamp()}` },
|
||
body: gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, boundsCoverAssetId),
|
||
});
|
||
const boundsGameId = boundsGameCreated.data?.id;
|
||
check(
|
||
'定价边界用例的准备游戏创建成功',
|
||
boundsGameCreated.status === 200 && Boolean(boundsGameId),
|
||
`status=${boundsGameCreated.status} gameId=${String(boundsGameId)}`,
|
||
);
|
||
// 单游戏作者视图把自有条目挂在 `data.game` 下(`versions` 在条目里,不在顶层)。
|
||
const countBoundsVersions = async () => {
|
||
const ownerEntry = await api(
|
||
`/api/game-distribution/my-games/${boundsGameId}`,
|
||
{ token: boundsAuthor.token },
|
||
);
|
||
const entry = ownerEntry.data?.game ?? ownerEntry.data;
|
||
return (entry?.versions ?? []).length;
|
||
};
|
||
const declareVersion = (price, suffix) =>
|
||
api(`/api/game-distribution/games/${boundsGameId}/versions`, {
|
||
method: 'POST',
|
||
token: boundsAuthor.token,
|
||
headers: { 'Idempotency-Key': `e2e-bounds-version-${suffix}-${stamp()}` },
|
||
body: {
|
||
priceMudPoints: price,
|
||
packageSha256: 'a'.repeat(64),
|
||
packageBytes: 1024,
|
||
packageFileCount: 1,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: gameMetadata(
|
||
`定价边界验证 ${suffix}`,
|
||
boundsCoverAssetId,
|
||
),
|
||
},
|
||
});
|
||
const tooHigh = await declareVersion(1_000_001, 'toohigh');
|
||
check(
|
||
'priceMudPoints=1000001 被 400 拒绝',
|
||
tooHigh.status === 400,
|
||
`status=${tooHigh.status} code=${tooHigh.error?.code ?? ''} msg=${tooHigh.error?.message ?? ''}`,
|
||
);
|
||
const versionsAfterTooHigh = await countBoundsVersions();
|
||
const rowsAfterTooHigh = spacetimeCount(
|
||
`SELECT COUNT(*) AS c FROM game_distribution_version WHERE game_id = '${boundsGameId}'`,
|
||
);
|
||
check(
|
||
'越界价格不落版本',
|
||
versionsAfterTooHigh === 0 &&
|
||
(rowsAfterTooHigh === null || rowsAfterTooHigh === 0),
|
||
`ownerVersions=${versionsAfterTooHigh} tableRows=${String(rowsAfterTooHigh)}`,
|
||
);
|
||
if (rowsAfterTooHigh === null) {
|
||
warn('版本表直查不可用', '越界不落版本只由作者自有列表断言');
|
||
}
|
||
const maxPrice = await declareVersion(1_000_000, 'max');
|
||
check(
|
||
'priceMudPoints=1000000 边界可通过',
|
||
maxPrice.status === 200 && Boolean(maxPrice.data?.versionId),
|
||
`status=${maxPrice.status} msg=${maxPrice.error?.message ?? ''}`,
|
||
);
|
||
const freePrice = await declareVersion(0, 'free');
|
||
check(
|
||
'priceMudPoints=0(免费)边界可通过',
|
||
freePrice.status === 200 && Boolean(freePrice.data?.versionId),
|
||
`status=${freePrice.status} msg=${freePrice.error?.message ?? ''}`,
|
||
);
|
||
const boundsFinalVersions = await countBoundsVersions();
|
||
const boundsFinalRows = spacetimeCount(
|
||
`SELECT COUNT(*) AS c FROM game_distribution_version WHERE game_id = '${boundsGameId}'`,
|
||
);
|
||
check(
|
||
'越界被拒后边界价各落一个版本(共 2 个)',
|
||
boundsFinalVersions === 2 &&
|
||
(boundsFinalRows === null || boundsFinalRows === 2),
|
||
`ownerVersions=${boundsFinalVersions} tableRows=${String(boundsFinalRows)}`,
|
||
);
|
||
|
||
// 14. 下架后失效:旧播放会话与公开入口都必须关闭(放在最后,会改动付费作品的公开态)
|
||
const paidBeforeUnpublish = await api(
|
||
`/api/game-distribution/games/${paid.gameId}`,
|
||
{ token: buyer.token },
|
||
);
|
||
const unpublished = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/unpublish`,
|
||
{
|
||
method: 'POST',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': `e2e-unpublish-${stamp()}` },
|
||
body: {
|
||
expectedPublicationRevision:
|
||
paidBeforeUnpublish.data?.publicationRevision ?? 0,
|
||
},
|
||
},
|
||
);
|
||
check(
|
||
'作者下架付费作品成功',
|
||
unpublished.status === 200,
|
||
`status=${unpublished.status} code=${unpublished.error?.code ?? ''} msg=${unpublished.error?.message ?? ''}`,
|
||
);
|
||
const staleEntry = await fetch(`${API}${playUrl}`);
|
||
const staleEntryBody = await staleEntry.text();
|
||
check(
|
||
'下架后旧播放会话入口 404',
|
||
staleEntry.status === 404,
|
||
`status=${staleEntry.status} bytes=${staleEntryBody.length}`,
|
||
);
|
||
const staleAsset = await fetch(`${API}${playUrl}${paid.pkg.assetPath}`);
|
||
check(
|
||
'下架后旧播放会话包内资源 404',
|
||
staleAsset.status === 404,
|
||
`status=${staleAsset.status}`,
|
||
);
|
||
const detailAfterUnpublish = await api(
|
||
`/api/game-distribution/games/${paid.gameId}`,
|
||
);
|
||
check(
|
||
'下架后公开详情不再返回入口',
|
||
detailAfterUnpublish.status === 404 ||
|
||
detailAfterUnpublish.data?.currentVersion?.entryUrl == null,
|
||
`status=${detailAfterUnpublish.status} entryUrl=${String(detailAfterUnpublish.data?.currentVersion?.entryUrl)}`,
|
||
);
|
||
const sessionAfterUnpublish = await api(
|
||
`/api/game-distribution/games/${paid.gameId}/play-session`,
|
||
{ method: 'POST', token: buyer.token },
|
||
);
|
||
check(
|
||
'下架后不能为付费作品创建新的播放会话',
|
||
sessionAfterUnpublish.status === 404,
|
||
`status=${sessionAfterUnpublish.status} code=${sessionAfterUnpublish.error?.code ?? ''}`,
|
||
);
|
||
|
||
console.log(
|
||
`\n[e2e] 断言汇总:${passes} PASS / ${failures} FAIL / ${warnings} WARN / ${skips.length} SKIP`,
|
||
);
|
||
if (skips.length > 0) {
|
||
console.log('[e2e] SKIP 清单(缺配置未验证,不等于通过):');
|
||
for (const entry of skips) {
|
||
console.log(
|
||
` - ${entry.name}${entry.detail ? ` :: ${entry.detail}` : ''}`,
|
||
);
|
||
}
|
||
}
|
||
return failures === 0 ? 0 : 1;
|
||
}
|
||
|
||
let exitCode = 1;
|
||
try {
|
||
exitCode = await main();
|
||
} catch (error) {
|
||
console.error(`[e2e] 执行失败: ${error?.message ?? error}`);
|
||
exitCode = 1;
|
||
} finally {
|
||
stopStack(stackDumpLogs);
|
||
}
|
||
process.exit(exitCode);
|