精简游戏评价回归测试与读取实现
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m0s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m55s
Project CI / Backend tests (pull_request) Successful in 5m33s
Project CI / Native shell tests (pull_request) Successful in 6m10s
Project CI / Frontend tests (pull_request) Successful in 2m18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 11m16s
Project CI / Repository checks (pull_request) Successful in 2m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m41s

将后台评价功能回归与旧库迁移验收解耦,独立准备隔离账号凭据
收窄重复输入矩阵和测试专用竞态场景,保留安全与幂等覆盖
复用公开版本判断,减少评价列表的重复过滤和中间集合
同步回归步骤与本轮验证证据
This commit is contained in:
2026-10-01 12:43:02 +08:00
parent cb689fea2a
commit 2c084d4ea4
6 changed files with 110 additions and 122 deletions
@@ -236,14 +236,9 @@ async function main() {
);
await invisible(privateGame, author.token, '未公开游戏');
for (const [label, body, status] of [
['未选择评分', { comment: '仅文字' }, 400],
['非整数', { score: 1.5 }, 400],
['字符串评分', { score: '5' }, 400],
['评论类型', { score: 5, comment: 42 }, 400],
['0 分', { score: 0 }, 422],
['11 分', { score: 11 }, 422],
['4001 emoji', { score: 5, comment: '😀'.repeat(4001) }, 422],
['4001 空白先校验', { score: 5, comment: ' '.repeat(4001) }, 422],
])
check(
`${label}拒绝 ${status}`,
@@ -405,14 +400,7 @@ async function main() {
(await list(gameId, '?pageSize=1')).data.reviews.length === 1 &&
(await list(gameId, '?pageSize=50')).data.reviews.length === 21,
);
for (const query of [
'?page=0',
'?page=-1',
'?page=1.5',
'?page=abc',
'?pageSize=0',
'?pageSize=51',
]) {
for (const query of ['?page=0', '?pageSize=51']) {
check(
`非法分页 ${query} 返回 400`,
(await list(gameId, query)).status === 400,
@@ -1,10 +1,11 @@
// 后台评价管理真实验收:旧库snapshot → 无损发布新模块 → migration → check。
// E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-review-moderation-e2e.mjs snapshot|migration|check
// 当前schema回归:prepare → 注入owner凭据并重启API → ratings-e2e新fixture → check。
// 旧库迁移独立验收:旧schema运行ratings-e2e → snapshot → 无损发布新模块/API → migration。
// E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-review-moderation-e2e.mjs prepare|snapshot|migration|check
// 复用 ratings-e2e 产生的21条评价与 .app/reviews-e2e-browser.json;凭据/快照只保存ignored .app。
// API重启前从 .app/review-moderation-e2e/credentials.json 注入 owner 用户名/密码。
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const state = JSON.parse(readFileSync('.app/dev-stack.json', 'utf8'));
const database = process.env.E2E_REVIEWS_DATABASE;
@@ -17,14 +18,17 @@ assert(
['127.0.0.1', 'localhost'].includes(new URL(base).hostname),
'只允许本机API',
);
const fixture = JSON.parse(
readFileSync('.app/reviews-e2e-browser.json', 'utf8'),
);
const mode = process.argv[2] ?? 'check';
const fixture =
mode === 'prepare'
? null
: JSON.parse(readFileSync('.app/reviews-e2e-browser.json', 'utf8'));
const dir = resolve('.app/review-moderation-e2e');
const credentialPath = resolve(dir, 'credentials.json');
const snapshotPath = resolve(dir, 'old-reviews.json');
const adminPrefix = '/admin/api/game-distribution';
const publicPrefix = `/api/game-distribution/games/${fixture.gameId}`;
const publicPrefix =
fixture && `/api/game-distribution/games/${fixture.gameId}`;
let checks = 0;
function check(label, ok) {
assert(ok, label);
@@ -68,6 +72,27 @@ const oldFields = (review) =>
(key) => [key, review[key]],
),
);
function prepare() {
mkdirSync(dir, { recursive: true });
if (!existsSync(credentialPath))
writeFileSync(
credentialPath,
JSON.stringify(
{
owner: {
username: 'reviews_e2e_owner',
password: `Rev-${randomUUID()}!`,
},
},
null,
2,
),
{ mode: 0o600 },
);
console.log(
'owner凭据已准备;从ignored credentials.json注入API环境变量后重启API。',
);
}
async function snapshot() {
const reviews = await api(`${publicPrefix}/reviews?pageSize=50`);
assert(
@@ -78,6 +103,7 @@ async function snapshot() {
reviews.data.reviews.every((review) => !Object.hasOwn(review, 'isHidden')),
'此模式只接受未追加isHidden的旧schema API',
);
mkdirSync(dir, { recursive: true });
writeFileSync(
snapshotPath,
JSON.stringify(
@@ -90,31 +116,8 @@ async function snapshot() {
2,
),
);
if (!existsSync(credentialPath))
writeFileSync(
credentialPath,
JSON.stringify(
{
owner: {
username: 'reviews_e2e_owner',
password: `Rev-${randomUUID()}!`,
},
allowed: {
username: `reviews_allowed_${Date.now()}`,
password: `Rev-${randomUUID()}!`,
},
denied: {
username: `reviews_denied_${Date.now()}`,
password: `Rev-${randomUUID()}!`,
},
},
null,
2,
),
{ mode: 0o600 },
);
console.log(
`旧schema快照已保存:${reviews.data.total}条;凭据仅在ignored文件。`,
`旧schema快照已保存:${reviews.data.total}条;快照仅在ignored文件。`,
);
}
async function migration() {
@@ -152,10 +155,9 @@ async function run() {
const health = await fetch(`${base}/healthz`);
check('API健康', health.ok);
assert(
existsSync(resolve(dir, 'migration-verified.json')),
'先运行migration保存存量验证证据',
existsSync(credentialPath),
'先运行prepare,并向API注入owner凭据后重启',
);
const old = JSON.parse(readFileSync(snapshotPath, 'utf8'));
const credentials = JSON.parse(readFileSync(credentialPath, 'utf8'));
const ownerLogin = await login(credentials.owner, true);
const owner = ownerLogin.token;
@@ -163,6 +165,10 @@ async function run() {
['allowed', ['game-reviews']],
['denied', ['game-management']],
]) {
credentials[name] = {
username: `reviews_${name}_${randomUUID().replaceAll('-', '')}`,
password: `Rev-${randomUUID()}!`,
};
const created = await api('/admin/api/accounts', {
method: 'POST',
token: owner,
@@ -176,6 +182,9 @@ async function run() {
});
assert(created.status === 200, `后台fixture账号创建失败 ${created.status}`);
}
writeFileSync(credentialPath, JSON.stringify(credentials, null, 2), {
mode: 0o600,
});
const allowedLogin = await login(credentials.allowed, true);
const allowed = allowedLogin.token;
const denied = (await login(credentials.denied, true)).token;
@@ -226,6 +235,7 @@ async function run() {
all.data.total >= 21 &&
all.headers.get('cache-control') === 'no-store',
);
const baseline = all.data.reviews.map(oldFields);
const page2 = await query(listPath + '&page=2');
check(
'后台默认分页20与跨页',
@@ -268,13 +278,8 @@ async function run() {
);
for (const [label, request, key, expected] of [
['隐藏空原因', body('hide', ' '), randomUUID(), 422],
['删除缺原因', body('delete'), randomUUID(), 422],
['原因4001码点', body('hide', '😀'.repeat(4001)), randomUUID(), 422],
['非法动作', body('unknown', 'x'), randomUUID(), 400],
['无创建时间', { action: 'hide', reason: 'x' }, randomUUID(), 400],
['错误时间', body('hide', 'x', 'not-a-date'), randomUUID(), 400],
['缺key', body('hide', 'x'), undefined, 400],
['过长key', body('hide', 'x'), 'k'.repeat(129), 400],
]) {
const response =
key === undefined
@@ -287,6 +292,13 @@ async function run() {
check(`${label}拒绝`, response.status === expected);
}
const preHide = (await myReview()).data.review;
check(
'无效管理请求没有变更或操作记录',
JSON.stringify(oldFields(preHide)) ===
JSON.stringify(oldFields(edited.data.review)) &&
!preHide.isHidden &&
(await detail()).data.operations.length === 0,
);
const total = (await publicReviews()).data.total;
const hideKey = randomUUID();
const hidden = await moderation(
@@ -465,19 +477,14 @@ async function run() {
(await query(listPath + '&pageSize=50')).data.reviews
.filter((row) => row.id !== before.id)
.every((row) => {
const previous = old.reviews.find((value) => value.id === row.id);
const previous = baseline.find((value) => value.id === row.id);
return (
previous &&
JSON.stringify(oldFields(row)) === JSON.stringify(previous)
);
}),
);
for (const suffix of [
'?status=unknown',
'?page=0',
'?pageSize=51',
'?page=1.5',
])
for (const suffix of ['?status=unknown', '?page=0'])
check(`错误筛选${suffix}400`, (await query(suffix)).status === 400);
const privateGames = await api(
`${adminPrefix}/user-review-games?pageSize=50`,
@@ -596,13 +603,16 @@ async function run() {
`全部${checks}项通过;browser fixture 已写入ignored .app/review-moderation-e2e/browser.json。`,
);
}
await (
process.argv[2] === 'snapshot'
? snapshot()
: process.argv[2] === 'migration'
? migration()
: run()
).catch((error) => {
console.error(`FAIL ${error.message}`);
process.exitCode = 1;
});
const modes = { prepare, snapshot, migration, check: run };
await Promise.resolve()
.then(() => {
assert(
Object.hasOwn(modes, mode),
'模式须为prepare、snapshot、migration或check',
);
return modes[mode]();
})
.catch((error) => {
console.error(`FAIL ${error.message}`);
process.exitCode = 1;
});