精简游戏评价回归测试与读取实现
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
@@ -383,6 +383,7 @@
### 迁移与验收
- 日常评价回归与旧库迁移验收独立运行。先用 `check-game-distribution-ratings-e2e.mjs` 在显式指定的本机 `reviews-e2e` 隔离库生成新 fixture,再运行 `check-game-distribution-review-moderation-e2e.mjs check`;后台凭据通过该脚本的 `prepare` 模式提前准备,按现有后台 owner 配置启动 API。`check` 以本次读取的评价为基线,不依赖旧快照或迁移证据;`snapshot` / `migration` 仅用于下面的旧库升级验收。非法输入的完整矩阵保留在领域/API 测试,真实 HTTP 回归保留代表性拒绝、无写入及权限、统计、幂等与删除重建验证。
- 将隐藏字段追加在已有 Rust 表结构末尾并设置明确默认值 false,按 SpacetimeDB 2.8.3 兼容追加规则发布;新增管理记录表为空。不手工回填或清空旧评价。
- 同步 `migration.rs`、后端表目录、生成绑定和 schema 检查。上线前用隔离数据库验证旧 schema 的评价升级后内容、唯一性、时间保留且全部公开;不把新数据库空表测试当成存量迁移证据。
- 回滚应用时保留新增字段和管理记录;旧后端不认识隐藏状态,会重新暴露已隐藏评价,因此产生隐藏记录后禁止直接回退到未过滤隐藏记录的旧读取实现,采用兼容修复。
@@ -400,4 +401,6 @@
真实运行时通过项目 dev 脚本启动隔离数据库与 API(`/healthz` 正常);旧 wasm 带 21 条评价兼容升级,逐项核对 ID、正文、评分、创建/修改时间均保持且隐藏默认 false。`scripts/check-game-distribution-review-moderation-e2e.mjs` 完成 57 项 HTTP 检查,另验证伪造管理员身份不能覆盖认证操作人。浏览器验证仅操作本地合成账号与游戏,没有生产数据写入或 OSS 上传。
回归脚本精简后,在现有本机隔离服务上以新 fixture 复跑:网站 40 项、后台 51 项 HTTP 检查通过,后台 `prepare` / `check` 不再依赖旧库验收产物。网站交互 11 项、评价领域 5 项、Spacetime module 编译、网站类型检查及定向格式/静态检查通过。此轮未重新发布 module 或重跑旧库迁移、浏览器验收;HTTP 结果用于验证脚本回归流程,新的内部读取简化由编译和源码条件对照验证。
未验证:生产部署、真实移动设备/系统输入法、无关全量测试和真实游戏包游玩。永久删除由真实 HTTP 验证,浏览器仅验证提示与取消;网络失败草稿由交互自动化验证,未在浏览器断网复现。用户验收尚未完成,计划和里程碑保留;不据此宣称已上线。
@@ -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;
});
@@ -173,7 +173,6 @@ mod tests {
fn review_identity_sort_and_summary_are_stable() {
assert_ne!(review_id("a", "bc"), review_id("ab", "c"));
assert_ne!(review_id("a:", "b"), review_id("a", ":b"));
assert_eq!(review_id("中文", "😀"), review_id("中文", "😀"));
assert_eq!(review_summary([].into_iter()), (None, 0));
assert_eq!(review_summary([8, 8, 9].into_iter()), (Some(8.3), 3));
assert_eq!(review_summary([10, 1].into_iter()), (Some(5.5), 2));
@@ -96,13 +96,14 @@ fn ensure_game_distribution_review_visible(
ctx: &ReducerContext,
game_id: &str,
) -> Result<(), String> {
let visible = get_public_game_distribution_game_tx(
ctx,
GameDistributionPublicGameInput {
game_id: game_id.to_string(),
},
)?
.is_some();
let visible = ctx
.db
.game_distribution_game()
.game_id()
.find(game_id.to_string())
.filter(|game| game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED)
.and_then(|game| public_game_distribution_version(ctx, &game))
.is_some();
if visible {
Ok(())
} else {
@@ -165,11 +166,8 @@ pub fn list_game_distribution_user_reviews_and_return(
.filter(&game_id)
.filter(|review| !review.is_hidden)
.collect::<Vec<_>>();
let (average_score, rating_count) = module_game_distribution::visible_review_summary(
reviews
.iter()
.map(|review| (review.score, review.is_hidden)),
);
let (average_score, rating_count) =
module_game_distribution::review_summary(reviews.iter().map(|review| review.score));
reviews.sort_by(|left, right| {
module_game_distribution::review_order(
left.created_at.to_micros_since_unix_epoch(),
@@ -470,7 +468,7 @@ pub fn list_admin_game_distribution_user_reviews_and_return(
.keyword
.clone()
.and_then(normalize_game_distribution_optional);
let candidates = if let Some(game_id) = &game_id {
let mut reviews = if let Some(game_id) = &game_id {
tx.db
.game_distribution_review()
.by_game_distribution_review_game_id()
@@ -485,21 +483,18 @@ pub fn list_admin_game_distribution_user_reviews_and_return(
} else {
tx.db.game_distribution_review().iter().collect::<Vec<_>>()
};
let mut reviews = candidates
.into_iter()
.filter(|review| {
module_game_distribution::review_matches_filters(
&review.game_id,
&review.user_id,
&review.comment,
review.is_hidden,
game_id.as_deref(),
user_id.as_deref(),
keyword.as_deref(),
&input.status,
)
})
.collect::<Vec<_>>();
reviews.retain(|review| {
module_game_distribution::review_matches_filters(
&review.game_id,
&review.user_id,
&review.comment,
review.is_hidden,
game_id.as_deref(),
user_id.as_deref(),
keyword.as_deref(),
&input.status,
)
});
reviews.sort_by(|left, right| {
module_game_distribution::review_order(
left.created_at.to_micros_since_unix_epoch(),
@@ -3333,19 +3328,25 @@ fn game_distribution_version_snapshot_for_game(
snapshot
}
fn public_game_distribution_snapshot(
fn public_game_distribution_version(
ctx: &ReducerContext,
game: &GameDistributionGame,
) -> Option<GameDistributionPublicGameSnapshot> {
) -> Option<GameDistributionVersion> {
let version_id = game.active_version_id.as_ref()?;
let version = ctx
.db
ctx.db
.game_distribution_version()
.version_id()
.find(version_id)
.filter(|version| {
version.game_id == game.game_id && version.status == GAME_DISTRIBUTION_VERSION_PUBLISHED
})?;
})
}
fn public_game_distribution_snapshot(
ctx: &ReducerContext,
game: &GameDistributionGame,
) -> Option<GameDistributionPublicGameSnapshot> {
let version = public_game_distribution_version(ctx, game)?;
Some(GameDistributionPublicGameSnapshot {
game: game_distribution_game_snapshot(game),
current_version: Some(game_distribution_version_snapshot_for_game(ctx, &version)),
@@ -86,13 +86,7 @@ function deferred<T>() {
}
function Harness() {
const state = useGameReviews('game-1', true);
return (
<>
<button onClick={() => void state.loadMine()}>重读个人</button>
<button onClick={() => void state.loadPage(state.page)}>刷新列表</button>
<GameReviews state={state} canReview onLogin={mocks.login} />
</>
);
return <GameReviews state={state} canReview onLogin={mocks.login} />;
}
async function form() {
return screen.findByRole('textbox', { name: '评论(可选)' });
@@ -319,17 +313,14 @@ describe('游戏评价', () => {
expect(mocks.list.mock.calls[2]).toEqual(['game-1', 1]);
});
it('保存后迟到的个人与列表读取不能覆盖新记录或摘要', async () => {
const oldMine = deferred<{ review: GameDistributionReview | null }>();
it('保存后迟到的初始列表不能覆盖新摘要或结束刷新状态', async () => {
const oldList = deferred<GameDistributionReviewsResponse>();
const refreshed = deferred<GameDistributionReviewsResponse>();
const saving = deferred<{
review: GameDistributionReview;
ratingSummary: { averageScore: number; ratingCount: number };
}>();
mocks.mine
.mockResolvedValueOnce({ review: review() })
.mockReturnValueOnce(oldMine.promise);
mocks.mine.mockResolvedValueOnce({ review: review() });
mocks.list
.mockReturnValueOnce(oldList.promise)
.mockReturnValueOnce(refreshed.promise);
@@ -338,8 +329,6 @@ describe('游戏评价', () => {
fireEvent.click(await screen.findByRole('button', { name: '编辑评价' }));
fireEvent.change(await form(), { target: { value: '已保存' } });
fireEvent.click(screen.getByRole('button', { name: '提交' }));
// 保存仍在途时发出个人读取;保存成功后该读取不能覆盖权威记录。
fireEvent.click(screen.getByRole('button', { name: '重读个人' }));
const updated = review({ comment: '已保存' });
await act(async () =>
saving.resolve({
@@ -348,17 +337,15 @@ describe('游戏评价', () => {
}),
);
await screen.findByText('已保存');
mocks.list.mockReturnValueOnce(new Promise(() => {}));
fireEvent.click(screen.getByRole('button', { name: '刷新列表' }));
await act(async () => {
oldMine.resolve({ review: null });
oldList.resolve(list());
refreshed.reject(new Error('过期读取失败'));
});
expect(screen.getByText('已保存')).toBeTruthy();
expect(screen.getByText('9.0/10 · 1 人评分')).toBeTruthy();
expect(screen.queryByText('过期读取失败')).toBeNull();
expect(screen.getByText('正在加载评价…')).toBeTruthy();
await act(async () => refreshed.resolve(list([updated])));
expect(screen.queryByText('正在加载评价…')).toBeNull();
expect(screen.getAllByText('已保存')).toHaveLength(2);
});
it('快速翻页只接受最新请求,旧失败和加载结束不覆盖当前页', async () => {