Files
Genarrative/scripts/check-game-distribution-fork-authorization-e2e.mjs
T
suzmii ea411cf4de
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
test(游戏共创): 新增作品级共创授权端到端验收脚本
- 新增 scripts/check-game-distribution-fork-authorization-e2e.mjs(789 行):
  真实 HTTP + 浏览器端到端验收作者侧共创授权链路 —— 从 .app/dev-stack.json 读栈地址;
  开 game-distribution:publish 灰度;注册两个真实作者;上传封面并创建作品;
  断言三态提升阶梯(forbidden→nonCommercial→full)、降级 409、过期 CAS 409、
  未知档位 400、非作者 403、未带 Bearer 401、同 key 同 body 重放 replayed=true、
  以及越权/非法请求无副作用;浏览器侧用 Playwright 在 /games/mine 断言入口与三态
  编辑器(当前档位不可点、更高档位可点)并真跑一次网页提升,截图留档。
- package.json:新增 npm script check:game-distribution-fork-authorization-e2e,
  命名与既有 check:game-distribution-*-e2e 保持一致。
- 实测:本地 dev 栈 + Chrome(E2E_PLAYWRIGHT_DIR 临时 playwright)跑 48 项断言
  48 PASS / 0 FAIL;npm run check:encoding 通过。
2026-10-05 00:42:47 +08:00

790 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发「作品级共创授权(Fork authorization)」链路真实行为验收。
// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server [+ web])+ 管理员账号。
//
// 用法(Windows + bash 均可;只依赖 Node 内置模块 + 仓库已有依赖):
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
// node scripts/check-game-distribution-fork-authorization-e2e.mjs
// SKIP_BROWSER=1 只跑 HTTP 契约部分(跳过 Playwright 视觉步骤)
// E2E_API_BASE / E2E_WEB_BASE 覆盖地址;默认从 CWD 的 .app/dev-stack.json 读取(不写死端口)
// E2E_PLAYWRIGHT_DIR 指向临时装了 playwright 的目录(仓库 devDependencies 不含 playwright)
// E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器)
// E2E_FORK_ENTRY_LABEL / E2E_FORK_LABEL_FORBIDDEN / E2E_FORK_LABEL_NON_COMMERCIAL / E2E_FORK_LABEL_FULL
// 覆盖前端文案(前端尚未落地,默认值见下方常量)
//
// 参考的仓库既有脚本与实现(本脚本按同一风格写成,未猜测既有契约):
// [1] admin 登录 / 缺凭据退出码 2 / 灰度开关:scripts/check-game-distribution-web-e2e.mjs:22-31,150-156,176-187
// [2] 作者注册(/api/auth/entry,dev 自动注册):scripts/check-game-distribution-web-e2e.mjs:24,161-166
// [3] 从 .app/dev-stack.json 取地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27
// [4] 封面直传 + 确认("发布游戏必须提供封面"):scripts/check-game-distribution-owner-isolation.mjs:97-150
// [5] 建游戏 payload 与响应取值:scripts/check-game-distribution-owner-isolation.mjs:53-64,201-212
// [6] my-games 明细形状(data.game):server-rs/crates/api-server/src/modules/game_distribution.rs:1151-1200,2991-3016
// [7] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159
//
// 契约假设(⚠ 只读核查确认 master 尚未落地,脚本按目标契约断言):
// A. PUT /api/game-distribution/games/{game_id}/fork-authorization 在 master 不存在:
// 全仓库 grep `fork-authorization|forkAuthorization|fork_authorization` 无匹配;
// 受保护路由全量枚举见 modules/game_distribution.rs:296-355。
// B. game 载荷当前没有 forkAuthorization 字段:modules/game_distribution.rs:2952-2977。
// C. 因此步骤 5/6 在 master 上会 404 / 字段缺失并 FAIL —— 设计预期(功能落地后再跑)。
// D. 前端 /games/mine 的「共创授权」入口与三态文案同样未落地,故做成可覆盖常量。
// E. 响应包装:本脚本同时接受 `data.game` 与 `data` 两种形态,并同时接受
// `data.replayed` 与 `data.game.replayed`(既有写接口把 `replayed` 放在 data 顶层,
// 例:modules/game_distribution.rs:682-700 的评价管理响应)。
import { readFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
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';
const SKIP_BROWSER = (process.env.SKIP_BROWSER ?? '').trim() === '1';
// 三态档位的线上取值(契约值,不随文案变化)。
const FORK_TIERS = ['forbidden', 'nonCommercial', 'full'];
// 前端文案(对齐 packages/shared/src/contracts/gameDistribution.ts:130-148),可用环境变量覆盖。
const FORK_ENTRY_LABEL = process.env.E2E_FORK_ENTRY_LABEL ?? '共创授权';
const FORK_TIER_LABELS = {
forbidden: process.env.E2E_FORK_LABEL_FORBIDDEN ?? '禁止共创',
nonCommercial: process.env.E2E_FORK_LABEL_NON_COMMERCIAL ?? '允许非商用共创',
full: process.env.E2E_FORK_LABEL_FULL ?? '允许全开放共创',
};
const FORK_TIER_SHORT_LABELS = {
forbidden: process.env.E2E_FORK_SHORT_FORBIDDEN ?? '禁止',
nonCommercial: process.env.E2E_FORK_SHORT_NON_COMMERCIAL ?? '非商用',
full: process.env.E2E_FORK_SHORT_FULL ?? '全开放',
};
const FORK_FULL_BADGE = process.env.E2E_FORK_FULL_BADGE ?? '共创授权已达上限';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
'game-distribution:publish 写入口并验收共创授权;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' +
'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
// 地址一律从 CWD 的 dev 栈状态文件读取,不写死端口(对齐 ratings-e2e.mjs:15-27)。
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, '');
const WEB = (
process.env.E2E_WEB_BASE ??
devStack.services?.web?.url ??
'http://127.0.0.1:3000'
).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;
let skipped = 0;
function check(name, ok, detail = '') {
checks += 1;
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
function skip(name, detail = '') {
skipped += 1;
console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`);
}
// ---------- HTTP helpers(对齐 owner-isolation.mjs:36-76) ----------
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {} } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
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(30_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,
};
}
/// 负面用例的可读诊断:状态 + 错误码 + 响应文本片段(失败时能直接看出是哪个层拒的)。
function brief(body) {
const code = body?.error?.code ?? body?.json?.error?.code ?? '';
return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 200)}`;
}
/// 兼容 `data.game` 与扁平 `data` 两种响应形态,并抽出 `replayed`。
function forkState(body) {
const data = body?.data ?? null;
const game = data?.game ?? data ?? null;
const replayed =
data?.replayed ?? data?.game?.replayed ?? game?.replayed ?? null;
return { game, replayed, forkAuthorization: game?.forkAuthorization ?? null };
}
/// 断言某个响应里的 game 载荷带有合法的 forkAuthorization,并等于期望档位。
function checkForkTier(name, body, expected, { expectReplayed } = {}) {
const { game, replayed, forkAuthorization } = forkState(body);
const shapeOk =
game !== null &&
typeof game === 'object' &&
Object.hasOwn(game, 'forkAuthorization');
const allowed = FORK_TIERS.includes(forkAuthorization);
const detail =
`status=${body?.status} forkAuthorization=${JSON.stringify(forkAuthorization)} ` +
`replayed=${JSON.stringify(replayed)} shapeOk=${shapeOk} allowed=${allowed}`;
check(
`${name}(契约形状:game.forkAuthorization 存在且取值合法)`,
shapeOk && allowed,
detail,
);
check(
`${name}(档位 === ${expected})`,
forkAuthorization === expected,
detail,
);
if (expectReplayed !== undefined) {
check(
`${name}(replayed === ${expectReplayed})`,
replayed === expectReplayed,
detail,
);
}
return { game, replayed, forkAuthorization };
}
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 };
}
function gameMetadata(title) {
return {
title,
summary: '共创授权验收临时游戏',
description: '',
category: '休闲',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
// 建游戏必须有封面(modules/game_distribution.rs:2806-2814),走现役直传 + 确认链路
// (对齐 owner-isolation.mjs:97-150;只往 dev bucket 写一个 67 字节 PNG)。
async function uploadCover(token, id) {
const fileName = `fork-authorization-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'fork-authorization', 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-fork-authorization',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
// ---------- Playwright helpers(对齐 web-publish-e2e.mjs:51-57,74-90,148-159) ----------
async function loadPlaywright() {
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
if (!dir) return import('playwright');
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
return requireFromDir('playwright');
}
async function loginThroughWebUi(page, phone) {
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
await page.locator('.platform-account-entry').first().click();
await page.getByRole('tab', { name: '密码登录' }).first().click();
await page.waitForTimeout(600);
await page
.getByLabel('同意法律协议')
.first()
.check({ force: true })
.catch(() => {});
await page
.locator('input[placeholder="13800000000"]:visible')
.first()
.fill(phone);
await page
.locator('input[placeholder="输入密码"]:visible')
.first()
.fill(DEV_PASSWORD);
await page.getByRole('button', { name: '登录', exact: true }).first().click();
await page
.locator('.platform-account-entry')
.first()
.filter({ hasText: phone.slice(-4) })
.waitFor({ state: 'visible', timeout: 30_000 });
}
/// 三态选项的 DOM 契约尚未落地:按常见可交互语义逐个尝试定位。
async function findTierOption(page, label) {
const candidates = [
page.getByRole('radio', { name: label }),
page.getByRole('menuitemradio', { name: label }),
page.getByRole('button', { name: label }),
page.locator('[role="option"]').filter({ hasText: label }),
page.locator('label').filter({ hasText: label }),
page.getByText(label, { exact: false }),
];
for (const locator of candidates) {
const first = locator.first();
const count = await first.count().catch(() => 0);
if (count > 0 && (await first.isVisible().catch(() => false))) {
return first;
}
}
return null;
}
/// `isDisabled()` 覆盖 button/input/select/[aria-disabled];取不到时退回 aria-disabled 属性。
async function isTierDisabled(locator) {
const disabled = await locator.isDisabled().catch(() => null);
if (disabled !== null) return { disabled, source: 'isDisabled' };
const aria = await locator.getAttribute('aria-disabled').catch(() => null);
if (aria !== null)
return { disabled: aria === 'true', source: 'aria-disabled' };
return { disabled: null, source: 'unknown' };
}
// ---------- 浏览器视觉步骤 ----------
async function runBrowserStep({ phone, title, draftTitle }) {
if (SKIP_BROWSER) {
skip('浏览器视觉:/games/mine 共创授权入口与三态', 'SKIP_BROWSER=1');
return;
}
let chromium;
try {
({ chromium } = await loadPlaywright());
} catch (error) {
check(
'浏览器视觉:Playwright 可用',
false,
`仓库 devDependencies 不含 playwright(package.json:255-282),请先执行 ` +
`\`npm install --prefix %TEMP%\\genarrative-pw --no-save --no-package-lock playwright\` ` +
`并设置 E2E_PLAYWRIGHT_DIR=%TEMP%\\genarrative-pw(写法见 web-publish-e2e.mjs:4-5);` +
`或设置 SKIP_BROWSER=1 只跑 HTTP 部分。原始错误:${error?.message ?? error}`,
);
return;
}
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const screenshotDir = await mkdtemp(
path.join(tmpdir(), 'genarrative-fork-e2e-'),
);
try {
const page = await browser.newPage({
viewport: { width: 1280, height: 900 },
});
await loginThroughWebUi(page, phone);
check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`);
await page.goto(`${WEB}/games/mine`, {
waitUntil: 'commit',
timeout: 120_000,
});
// A. HTTP 步骤已把作品提升到 full:卡片只读展示档位,不给入口。
const fullCard = page.getByText(title, { exact: false }).first();
const fullCardVisible = await fullCard
.waitFor({ state: 'visible', timeout: 60_000 })
.then(() => true)
.catch(() => false);
check(
'浏览器视觉:/games/mine 出现 full 档作品卡片',
fullCardVisible,
`title=${title}`,
);
check(
'浏览器视觉:full 档卡片显示档位文案',
await page
.getByText(`共创:${FORK_TIER_LABELS.full}`)
.first()
.isVisible()
.catch(() => false),
`label=共创:${FORK_TIER_LABELS.full}`,
);
const fullCardElement = page
.locator('article.my-game-card')
.filter({ hasText: title })
.first();
check(
'浏览器视觉:full 档卡片显示只读上限且不提供入口',
(await fullCardElement
.getByText(FORK_FULL_BADGE)
.first()
.isVisible()
.catch(() => false)) &&
(await fullCardElement
.getByRole('button', { name: FORK_ENTRY_LABEL, exact: true })
.count()) === 0,
`badge=${FORK_FULL_BADGE} card=article.my-game-card`,
);
// B. 全新草稿作品(forbidden):卡片有「共创授权」入口,点开后三态可选、当前档位不可点。
const draftCard = page.getByText(draftTitle, { exact: false }).first();
const draftCardVisible = await draftCard
.waitFor({ state: 'visible', timeout: 60_000 })
.then(() => true)
.catch(() => false);
check(
'浏览器视觉:/games/mine 出现草稿作品卡片',
draftCardVisible,
`title=${draftTitle}`,
);
check(
'浏览器视觉:草稿卡片档位文案为禁止共创',
await page
.getByText(`共创:${FORK_TIER_LABELS.forbidden}`)
.first()
.isVisible()
.catch(() => false),
`label=共创:${FORK_TIER_LABELS.forbidden}`,
);
const entry = page
.getByRole('button', { name: FORK_ENTRY_LABEL, exact: true })
.first();
const entryVisible = await entry
.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => true)
.catch(() => false);
check(
'浏览器视觉:草稿卡片上可见「共创授权」入口',
entryVisible,
`label=${FORK_ENTRY_LABEL}`,
);
if (entryVisible) {
await entry.click();
await page.waitForTimeout(600);
const options = {};
for (const tier of FORK_TIERS) {
options[tier] = await findTierOption(
page,
FORK_TIER_SHORT_LABELS[tier],
);
check(
`浏览器视觉:三态选项可见(${tier} = ${FORK_TIER_SHORT_LABELS[tier]})`,
options[tier] !== null,
`label=${FORK_TIER_SHORT_LABELS[tier]}`,
);
}
// 当前档位 forbidden、无更低档位:当前档位必须不可点,更高档位必须可点。
for (const tier of FORK_TIERS) {
const locator = options[tier];
if (!locator) {
check(
`浏览器视觉:${tier} 档位禁用态`,
false,
`未定位到 ${FORK_TIER_SHORT_LABELS[tier]} 选项元素,无法判定禁用态`,
);
continue;
}
const { disabled, source } = await isTierDisabled(locator);
const shouldBeDisabled = tier === 'forbidden';
check(
`浏览器视觉:${tier} 档位${shouldBeDisabled ? '不可点(当前档位)' : '可点(更高档位)'}`,
disabled === shouldBeDisabled,
disabled === null
? `label=${FORK_TIER_SHORT_LABELS[tier]} 无法判定禁用态(尝试过 isDisabled 与 aria-disabled)`
: `label=${FORK_TIER_SHORT_LABELS[tier]} source=${source} disabled=${disabled}`,
);
}
const panelShot = path.join(
screenshotDir,
'fork-authorization-panel.png',
);
await page.screenshot({ path: panelShot, fullPage: true });
check('浏览器视觉:三态面板已截图', true, `截图路径=${panelShot}`);
// C. 走一次真实提升(禁止 → 非商用):确认面板 + 卡片文案回读。
const promote = options.nonCommercial;
if (promote) {
await promote.click();
await page.waitForTimeout(300);
check(
'浏览器视觉:点击非商用后出现确认提升提示',
await page
.getByText(
new RegExp(`确认提升为「${FORK_TIER_LABELS.nonCommercial}」`),
)
.first()
.isVisible()
.catch(() => false),
`期望包含 确认提升为「${FORK_TIER_LABELS.nonCommercial}」`,
);
const confirmButton = page
.getByRole('button', { name: '确认提升', exact: true })
.first();
const confirmVisible = await confirmButton
.isVisible()
.catch(() => false);
check(
'浏览器视觉:确认提升按钮可见',
confirmVisible,
'button=确认提升',
);
if (confirmVisible) {
await confirmButton.click();
check(
'浏览器视觉:网页提升到非商用后卡片文案更新',
await page
.getByText(`共创:${FORK_TIER_LABELS.nonCommercial}`)
.first()
.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => true)
.catch(() => false),
`label=共创:${FORK_TIER_LABELS.nonCommercial}`,
);
}
} else {
check('浏览器视觉:确认提升按钮可见', false, '未定位到非商用选项');
}
const afterShot = path.join(
screenshotDir,
'fork-authorization-after-promote.png',
);
await page.screenshot({ path: afterShot, fullPage: true });
check('浏览器视觉:提升后已截图', true, `截图路径=${afterShot}`);
}
const screenshotPath = path.join(
screenshotDir,
'fork-authorization-panel.png',
);
await page.screenshot({ path: screenshotPath, fullPage: true });
check('浏览器视觉:已截图保存', true, `截图路径=${screenshotPath}`);
} finally {
await browser.close().catch(() => {});
console.log(`[fork-e2e] 截图目录:${screenshotDir}`);
}
}
// ---------- 主流程 ----------
async function main() {
console.log(
`[fork-e2e] api-server=${API} web=${WEB} database=${devStack.database}`,
);
// 1) 管理员登录(对齐 web-e2e.mjs:150-156)
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);
// 2) 开灰度(对齐 web-e2e.mjs:176-187)
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}`);
// 3) 注册两个作者(对齐 web-e2e.mjs:161-166)
const stamp = Date.now();
const author = await register('132');
const other = await register('133');
check(
'作者注册拿到 token',
author.response.status === 200 && Boolean(author.token),
`status=${author.response.status} phone=${author.phone}`,
);
check(
'第二个账号注册拿到 token',
other.response.status === 200 && Boolean(other.token),
`status=${other.response.status} phone=${other.phone}`,
);
if (!author.token || !other.token) process.exit(1);
// 4) 创建作品(建游戏必须有封面:modules/game_distribution.rs:2806-2814)
const title = `共创授权 ${String(stamp).slice(-6)}`;
const coverAssetId = await uploadCover(author.token, stamp);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `fork-e2e-game-${stamp}` },
body: { ...gameMetadata(title), coverAssetId },
});
const gameId = created.data?.id;
check(
'作者创建作品成功并取到 game.id',
created.status === 200 && Boolean(gameId),
`status=${created.status} id=${gameId ?? ''} msg=${created.error?.message ?? ''}`,
);
if (!gameId) process.exit(1);
const forkPath = `/api/game-distribution/games/${gameId}/fork-authorization`;
const forkBody = (expected, next) => ({
expectedForkAuthorization: expected,
forkAuthorization: next,
});
// 幂等键:除"重放"用例复用同一把键外,每一步都是独立键。
const keyNonCommercial = `fork-e2e-nc-${stamp}`;
const keyFull = `fork-e2e-full-${stamp}`;
const bodyNonCommercial = forkBody('forbidden', 'nonCommercial');
const bodyFull = forkBody('nonCommercial', 'full');
// 5a) 初始档位必须是 forbidden(默认值)
const initial = await api(`/api/game-distribution/my-games/${gameId}`, {
token: author.token,
});
const initialGame = initial.data?.game ?? null;
check(
'初始 GET my-games/{game_id} 的 game 载荷存在',
initial.status === 200 && initialGame !== null,
`status=${initial.status}`,
);
checkForkTier('初始档位', initial, 'forbidden');
// 5b) forbidden → nonCommercial
const promoted = await api(forkPath, {
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': keyNonCommercial },
body: bodyNonCommercial,
});
check(
'提升到 nonCommercial 返回 200',
promoted.status === 200,
`status=${promoted.status} msg=${promoted.error?.message ?? ''}`,
);
checkForkTier('nonCommercial', promoted, 'nonCommercial', {
expectReplayed: false,
});
// 5c) nonCommercial → full
const upgraded = await api(forkPath, {
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': keyFull },
body: bodyFull,
});
check(
'提升到 full 返回 200',
upgraded.status === 200,
`status=${upgraded.status} msg=${upgraded.error?.message ?? ''}`,
);
checkForkTier('full', upgraded, 'full', { expectReplayed: false });
// 5d) 降级必须被拒(full → forbidden)→ 409
const downgrade = await api(forkPath, {
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': `fork-e2e-downgrade-${stamp}` },
body: forkBody('full', 'forbidden'),
});
check(
'降级 full → forbidden 被拒(409)',
downgrade.status === 409,
brief(downgrade),
);
// 5e) 过期 CAS 必须被拒(当前已是 full,却声明 expected=forbidden)→ 409
const staleCas = await api(forkPath, {
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': `fork-e2e-stale-${stamp}` },
body: forkBody('forbidden', 'full'),
});
check(
'过期 CAS(expected=forbidden,实际 full)被拒(409)',
staleCas.status === 409,
brief(staleCas),
);
// 5f) 未知档位必须被拒(allowed 不是三态之一)→ 400
const unknownTier = await api(forkPath, {
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': `fork-e2e-unknown-${stamp}` },
body: forkBody('full', 'allowed'),
});
check(
'未知档位 allowed 被拒(400)',
unknownTier.status === 400,
brief(unknownTier),
);
// 5g) 幂等重放:复用 nonCommercial → full 的同一把键与同一 body → 200 且 replayed=true
const replay = await api(forkPath, {
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': keyFull },
body: bodyFull,
});
check(
'幂等重放返回 200',
replay.status === 200,
`status=${replay.status} msg=${replay.error?.message ?? ''}`,
);
checkForkTier('幂等重放', replay, 'full', { expectReplayed: true });
// 5h) 非作者(第二个账号)必须被拒 → 403
const foreign = await api(forkPath, {
method: 'PUT',
token: other.token,
headers: { 'Idempotency-Key': `fork-e2e-foreign-${stamp}` },
body: forkBody('full', 'forbidden'),
});
check('非作者发起变更被拒(403)', foreign.status === 403, brief(foreign));
// 5i) 未带 Bearer 必须被拒 → 401
const anonymous = await api(forkPath, {
method: 'PUT',
headers: { 'Idempotency-Key': `fork-e2e-anon-${stamp}` },
body: forkBody('full', 'forbidden'),
});
check(
'未带 Authorization 被拒(401)',
anonymous.status === 401,
brief(anonymous),
);
// 6) 回读确认最终档位仍是 full(越权与非法请求都不得有副作用)
const finalRead = await api(`/api/game-distribution/my-games/${gameId}`, {
token: author.token,
});
check(
'最终 GET my-games/{game_id} 仍为 200',
finalRead.status === 200,
`status=${finalRead.status}`,
);
checkForkTier('最终档位(非法请求无副作用)', finalRead, 'full');
// 7) 浏览器视觉:需要一张 full 档作品(只读展示)与一张全新草稿作品(三态编辑器)。
const draftTitle = `共创授权草稿 ${String(stamp).slice(-6)}`;
const draftCoverAssetId = await uploadCover(author.token, `${stamp}-draft`);
const draftCreated = await api('/api/game-distribution/games', {
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `fork-e2e-draft-${stamp}` },
body: { ...gameMetadata(draftTitle), coverAssetId: draftCoverAssetId },
});
check(
'草稿作品创建成功(浏览器三态用例)',
draftCreated.status === 200 && Boolean(draftCreated.data?.id),
`status=${draftCreated.status} id=${draftCreated.data?.id ?? ''}`,
);
await runBrowserStep({ phone: author.phone, title, draftTitle });
console.log(
`[fork-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`,
);
if (failures > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(`[fork-e2e] 未捕获异常:${error?.stack ?? error}`);
process.exitCode = 1;
});