From 9662b56fa6a246d5a6c71591df0411fa5251e045 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 18:17:18 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B9=B0=E6=96=AD=E5=88=B6?= =?UTF-8?q?=E4=BB=98=E8=B4=B9=E9=AA=8C=E6=94=B6=E8=84=9A=E6=9C=AC=E7=9A=84?= =?UTF-8?q?=E5=85=8D=E8=B4=AD=E4=B9=B0=E3=80=81=E5=B9=B6=E5=8F=91=E4=B8=8E?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 作者本人免购买:详情有入口但 purchased=false、自购 400 GAME_PURCHASE_OWNER_EXEMPT 且余额与流水不变、可直接创建播放会话 - 管理员免购买:管理员令牌可直接创建播放会话并可玩;购买路径记录真实返回码(当前为用户鉴权前置 401,403 GAME_PURCHASE_ADMIN_NOT_ALLOWED 需带 admin 角色的用户令牌) - 真并发购买:同一未购买账号同时发两个不同幂等键,断言无 5xx、只扣一次、流水与购买记录各 1 条、详情 purchased=true - 审核员试玩待审付费版本:admin preview session 入口与包内资源同包可用、可反复创建不限次、钱包与购买记录不变 - 定价边界:priceMudPoints=1000001 被 400 拒绝且不落版本,0 与 1000000 可通过 - 下架后失效:旧播放会话入口与包内资源 404、公开详情与新建会话都关闭 - 购买记录/版本条数用 spacetime sql 直读本地表交叉验证,CLI 不可用时降级为 WARN - 脚本头部写明这是人工验收脚本、不在 CI 自动门禁内,且需要 E2E_ADMIN_USER/E2E_ADMIN_PASSWORD --- .../check-game-distribution-purchase-e2e.mjs | 554 +++++++++++++++++- 1 file changed, 540 insertions(+), 14 deletions(-) diff --git a/scripts/check-game-distribution-purchase-e2e.mjs b/scripts/check-game-distribution-purchase-e2e.mjs index 3fdd9c50b..dbeacdf06 100644 --- a/scripts/check-game-distribution-purchase-e2e.mjs +++ b/scripts/check-game-distribution-purchase-e2e.mjs @@ -1,5 +1,9 @@ // 游戏买断制泥点付费与播放鉴权「真实本地栈」端到端检查。 // +// 定位:人工验收脚本,**不在 CI 自动门禁内**(需要真实 SpacetimeDB + api-server + 可用的 +// OSS 凭据 + 管理员账号,冒烟时长与环境依赖都不适合放进流水线)。发布前由人工在本机 +// 或联调机上执行,失败即非零退出。 +// // 用法: // E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \ // npm run check:game-distribution-purchase-e2e @@ -12,6 +16,8 @@ // 主站 Vite 时,平台同源路径 `/games//` 只做 WARN,不误判为通过。 // E2E_PRICE_MUD_POINTS 覆盖付费游戏定价(默认 30)。 // E2E_ADMIN_USER / E2E_ADMIN_PASSWORD 必填,脚本不读取仓库内任何凭据文件。 +// 「购买记录条数」用 `spacetime sql` 直读 `game_distribution_purchase` 做交叉验证; +// CLI 不可用时该项降级为 WARN,其余断言仍按 HTTP/账单证据判定。 // // 覆盖: // 1. 作者发布付费游戏(priceMudPoints=N)→ 管理员审核通过。 @@ -25,6 +31,17 @@ // 按前缀清空 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'; @@ -255,21 +272,18 @@ function gameMetadata(title, coverAssetId) { }; } -/** 走完「建游戏 → 建版本(冻结价格)→ 传包 → 送审 → 管理员通过」的真实发布链路。 */ -async function publishGame({ authorToken, adminToken, coverAssetId, price, marker, title }) { +/** 建版本(冻结价格)→ 上传发行包 → 送审,返回待审版本与包内容。 */ +async function createVersionAndSubmit({ + authorToken, + gameId, + price, + marker, + title, + coverAssetId, + expectedPublicationRevision, +}) { 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 pkg = await buildPackage(marker); const version = await api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', @@ -306,11 +320,38 @@ async function publishGame({ authorToken, adminToken, coverAssetId, price, marke method: 'POST', token: authorToken, headers: { 'Idempotency-Key': `e2e-purchase-submit-${id}` }, - body: { expectedPublicationRevision: created.data.publicationRevision ?? 0 }, + 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, @@ -343,6 +384,66 @@ async function walletLedgerEntries(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//`。 */ async function fetchPlatformEntry(gameId, assetPath = '') { if (!WEB) return null; @@ -949,6 +1050,431 @@ async function main() { 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] 断言汇总:${failures === 0 ? '全部通过' : `${failures} 条失败`}`, );