实现网站游戏评分与评价 (#561)
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。 - 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。 - 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。 - 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。 验证: - 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。 - 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。 - 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。 发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #561.
This commit is contained in:
@@ -14,6 +14,8 @@ import fs from 'node:fs';
|
||||
|
||||
const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs';
|
||||
const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts';
|
||||
const ADMIN_RUST_FILE = 'server-rs/crates/shared-contracts/src/admin.rs';
|
||||
const ADMIN_TS_FILE = 'apps/admin-web/src/api/adminApiTypes.ts';
|
||||
const API_MODULE_FILE =
|
||||
'server-rs/crates/api-server/src/modules/game_distribution.rs';
|
||||
|
||||
@@ -33,6 +35,12 @@ const PAIRS = [
|
||||
['GameDistributionOrientation', 'GameDistributionOrientation'],
|
||||
['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'],
|
||||
['GameDistributionAuthor', 'GameDistributionAuthor'],
|
||||
['GameDistributionReview', 'GameDistributionReview'],
|
||||
['GameDistributionRatingSummary', 'GameDistributionRatingSummary'],
|
||||
['GameDistributionReviewsResponse', 'GameDistributionReviewsResponse'],
|
||||
['GameDistributionMyReviewResponse', 'GameDistributionMyReviewResponse'],
|
||||
['GameDistributionSaveReviewRequest', 'GameDistributionSaveReviewRequest'],
|
||||
['GameDistributionSaveReviewResponse', 'GameDistributionSaveReviewResponse'],
|
||||
['GameDistributionVersionSummary', 'GameDistributionVersionSummary'],
|
||||
['GameDistributionGameSummary', 'GameDistributionGame'],
|
||||
['GameDistributionListResponse', 'GameDistributionListResponse'],
|
||||
@@ -42,6 +50,17 @@ const PAIRS = [
|
||||
'GameDistributionCreateVersionRequest',
|
||||
],
|
||||
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
|
||||
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
|
||||
['AdminGameReviewGame', 'AdminGameReviewGame'],
|
||||
['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'],
|
||||
['AdminGameReviewsQuery', 'AdminGameReviewsQuery'],
|
||||
['AdminGameReviewGameInfo', 'AdminGameReviewGameInfo'],
|
||||
['AdminGameReview', 'AdminGameReview'],
|
||||
['AdminGameReviewOperation', 'AdminGameReviewOperation'],
|
||||
['AdminGameReviewsResponse', 'AdminGameReviewsResponse'],
|
||||
['AdminGameReviewDetailResponse', 'AdminGameReviewDetailResponse'],
|
||||
['AdminGameReviewModerationRequest', 'AdminGameReviewModerationRequest'],
|
||||
['AdminGameReviewModerationResponse', 'AdminGameReviewModerationResponse'],
|
||||
];
|
||||
|
||||
// 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型
|
||||
@@ -57,7 +76,7 @@ const TS_ONLY_TYPES = [
|
||||
];
|
||||
|
||||
// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。
|
||||
// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影 = 作者投影 + currentVersion);
|
||||
// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影追加 currentVersion、ratingSummary);
|
||||
// - `mustEmit`:TS 类型为了同时描述两种形状把该键标成可选,但这条路径必须发出。
|
||||
// 只解析顶层键;需要一并比对的嵌套对象用 `nested` 显式登记,没登记的嵌套对象不在证据范围内。
|
||||
const RESPONSE_BUILDERS = [
|
||||
@@ -73,7 +92,7 @@ const RESPONSE_BUILDERS = [
|
||||
fn: 'public_game_payload',
|
||||
ts: 'GameDistributionGame',
|
||||
base: 'game_payload',
|
||||
mustEmit: ['currentVersion'],
|
||||
mustEmit: ['currentVersion', 'ratingSummary'],
|
||||
},
|
||||
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
|
||||
{ fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' },
|
||||
@@ -101,7 +120,7 @@ function renamedMember(value, kind, rename) {
|
||||
function rustDefinitions(source) {
|
||||
const result = new Map();
|
||||
const pattern =
|
||||
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+)(?<body>[\s\S]*?)\n\}/g;
|
||||
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+|AdminGameReview\w*)(?<body>[\s\S]*?)\n\}/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(source))) {
|
||||
const { attrs, kind, name, body } = match.groups;
|
||||
@@ -122,7 +141,7 @@ function rustDefinitions(source) {
|
||||
function tsDefinitions(source) {
|
||||
const result = new Map();
|
||||
const objectPattern =
|
||||
/export type (GameDistribution\w+) = \{([\s\S]*?)\n\};/g;
|
||||
/export (?:type|interface) (GameDistribution\w+|AdminGameReview\w*)(?: =)? \{([\s\S]*?)\n\};?/g;
|
||||
let match;
|
||||
while ((match = objectPattern.exec(source))) {
|
||||
const members = [];
|
||||
@@ -135,7 +154,8 @@ function tsDefinitions(source) {
|
||||
}
|
||||
result.set(match[1], { kind: 'struct', members, required });
|
||||
}
|
||||
const unionPattern = /export type (GameDistribution\w+) =\s*([^;]+);/g;
|
||||
const unionPattern =
|
||||
/export type (GameDistribution\w+|AdminGameReview\w*) =\s*([^;]+);/g;
|
||||
while ((match = unionPattern.exec(source))) {
|
||||
if (result.has(match[1])) continue;
|
||||
result.set(match[1], {
|
||||
@@ -297,8 +317,16 @@ function insertedKeys(body) {
|
||||
);
|
||||
}
|
||||
|
||||
const rust = rustDefinitions(fs.readFileSync(RUST_FILE, 'utf8'));
|
||||
const ts = tsDefinitions(fs.readFileSync(TS_FILE, 'utf8'));
|
||||
const rust = rustDefinitions(
|
||||
fs.readFileSync(RUST_FILE, 'utf8') +
|
||||
'\n' +
|
||||
fs.readFileSync(ADMIN_RUST_FILE, 'utf8'),
|
||||
);
|
||||
const ts = tsDefinitions(
|
||||
fs.readFileSync(TS_FILE, 'utf8') +
|
||||
'\n' +
|
||||
fs.readFileSync(ADMIN_TS_FILE, 'utf8'),
|
||||
);
|
||||
const failures = [];
|
||||
const mappedRust = new Set(PAIRS.map(([rustName]) => rustName));
|
||||
const mappedTs = new Set(PAIRS.map(([, tsName]) => tsName));
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
// 网站游戏评价真实 HTTP/SpacetimeDB 验收,仅允许显式指定本机隔离数据库。
|
||||
// 先运行 npm run dev backend -- --database genarrative-reviews-e2e
|
||||
// --spacetime-data-dir server-rs/.spacetimedb/reviews-e2e/data --no-interactive
|
||||
// 再运行 E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-ratings-e2e.mjs
|
||||
// 地址与本地 runtime writer 凭据从当前 .app/dev-stack.json/data-dir 读取;不会输出凭据。
|
||||
// fixture 只调用已有数据库 procedure,不上传 OSS,也不验证发行包或真实游玩。
|
||||
// 创建 21 个临时账号、两个公开游戏及一个未公开游戏;记录留在隔离库供浏览器验收。
|
||||
// 清理由停止本次栈后删除明确指定的隔离 data-dir 完成,不删除或修改其它数据库。
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const state = JSON.parse(readFileSync(resolve('.app/dev-stack.json'), 'utf8'));
|
||||
const database = process.env.E2E_REVIEWS_DATABASE;
|
||||
assert(
|
||||
database && database === state.database,
|
||||
'请显式指定与当前 dev 栈一致的 E2E_REVIEWS_DATABASE',
|
||||
);
|
||||
assert(database.includes('reviews-e2e'), '本验收仅允许 reviews-e2e 隔离数据库');
|
||||
const apiBase = process.env.E2E_API_BASE ?? state.services['api-server'].url;
|
||||
const spacetimeBase = state.services.spacetime.url;
|
||||
for (const value of [apiBase, spacetimeBase]) {
|
||||
const url = new URL(value);
|
||||
assert(
|
||||
url.protocol === 'http:' &&
|
||||
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname),
|
||||
'仅允许本机 HTTP 栈',
|
||||
);
|
||||
}
|
||||
const identity = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(state.spacetimeDataDir, 'dev-api-identities/local-node.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert(identity.token, '本地 runtime writer 凭据缺失');
|
||||
let checks = 0;
|
||||
function check(label, condition) {
|
||||
assert(condition, label);
|
||||
checks += 1;
|
||||
console.log(`PASS ${label}`);
|
||||
}
|
||||
async function api(path, { token, body, method = 'GET' } = {}) {
|
||||
const headers = { 'x-genarrative-response-envelope': 'v1' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const json = await response.json();
|
||||
return {
|
||||
status: response.status,
|
||||
data: json.data,
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
let operation = 0;
|
||||
function receipt() {
|
||||
const key = `reviews-e2e-${randomUUID()}-${operation++}`;
|
||||
return {
|
||||
idempotency_key: key,
|
||||
request_digest: key,
|
||||
now_micros: Date.now() * 1000,
|
||||
};
|
||||
}
|
||||
async function procedure(name, input) {
|
||||
const response = await fetch(
|
||||
`${spacetimeBase}/v1/database/${encodeURIComponent(database)}/call/${name}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${identity.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([input]),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const diagnostic = await response.text();
|
||||
throw new Error(
|
||||
`${name}: HTTP ${response.status} ${diagnostic.slice(0, 400)}`,
|
||||
);
|
||||
}
|
||||
const result = await response.json();
|
||||
assert(
|
||||
(result.success ?? result[0]) === true,
|
||||
`${name}: fixture procedure 失败`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
const pathFor = (gameId) => `/api/game-distribution/games/${gameId}`;
|
||||
const list = (gameId, query = '') => api(`${pathFor(gameId)}/reviews${query}`);
|
||||
const mine = (gameId, token) => api(`${pathFor(gameId)}/my-review`, { token });
|
||||
const save = (gameId, token, body) =>
|
||||
api(`${pathFor(gameId)}/my-review`, { method: 'PUT', token, body });
|
||||
async function account(index, stamp) {
|
||||
const phone = `139${String(stamp + index).slice(-8)}`;
|
||||
const response = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: { purePhoneNumber: phone, password: 'ReviewsE2e123!' },
|
||||
});
|
||||
assert(
|
||||
response.status === 200 && response.data?.token && response.data?.user?.id,
|
||||
`账号 ${index} 登录失败 ${response.status}`,
|
||||
);
|
||||
return { token: response.data.token, user: response.data.user, phone };
|
||||
}
|
||||
async function createGame(owner, title) {
|
||||
const gameId = `game_${randomUUID().replaceAll('-', '')}`;
|
||||
await procedure('create_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
owner_user_id: owner.user.id,
|
||||
title,
|
||||
summary: '评价验收本地 fixture',
|
||||
description: null,
|
||||
category: '休闲',
|
||||
tags_json: '["e2e"]',
|
||||
cover_asset_id: null,
|
||||
author_name: [0, owner.user.displayName],
|
||||
author_avatar_url: null,
|
||||
device_support_desktop: true,
|
||||
device_support_mobile: true,
|
||||
device_support_touch: true,
|
||||
input_modes_json: '["keyboard","touch"]',
|
||||
orientation: 'landscape',
|
||||
local_project_id: null,
|
||||
...receipt(),
|
||||
});
|
||||
return gameId;
|
||||
}
|
||||
async function publish(gameId, owner, revision) {
|
||||
const versionId = `version_${randomUUID().replaceAll('-', '')}`;
|
||||
const metadata = {
|
||||
title: '网站评价验收',
|
||||
summary: '本地评价 fixture,发行资源不上传',
|
||||
category: '休闲',
|
||||
coverAssetId: 'reviews-e2e-cover',
|
||||
coverObjectKey: 'reviews-e2e/cover.png',
|
||||
tags: ['e2e'],
|
||||
deviceSupport: { desktop: true, mobile: true, touch: true },
|
||||
inputModes: ['keyboard', 'touch'],
|
||||
orientation: 'landscape',
|
||||
};
|
||||
const packageFields = {
|
||||
package_sha_256: 'a'.repeat(64),
|
||||
package_bytes: 1,
|
||||
package_file_count: 1,
|
||||
package_entry_path: 'index.html',
|
||||
};
|
||||
await procedure('create_game_distribution_version_and_return', {
|
||||
game_id: gameId,
|
||||
owner_user_id: owner.user.id,
|
||||
version_id: versionId,
|
||||
metadata_json: JSON.stringify(metadata),
|
||||
...packageFields,
|
||||
local_project_id: null,
|
||||
...receipt(),
|
||||
});
|
||||
const confirmReceipt = receipt();
|
||||
const { now_micros, ...confirmKeys } = confirmReceipt;
|
||||
await procedure('confirm_game_distribution_package_and_return', {
|
||||
version_id: versionId,
|
||||
owner_user_id: owner.user.id,
|
||||
...packageFields,
|
||||
package_object_key: 'reviews-e2e/package.zip',
|
||||
package_manifest_json: '{}',
|
||||
...confirmKeys,
|
||||
updated_at_micros: now_micros,
|
||||
});
|
||||
await procedure('submit_game_distribution_version_for_review_and_return', {
|
||||
version_id: versionId,
|
||||
owner_user_id: owner.user.id,
|
||||
expected_publication_revision: revision,
|
||||
...receipt(),
|
||||
});
|
||||
await procedure('approve_game_distribution_version_and_return', {
|
||||
version_id: versionId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: revision,
|
||||
entry_url: `http://127.0.0.1/games/${gameId}/index.html`,
|
||||
...receipt(),
|
||||
});
|
||||
}
|
||||
async function invisible(gameId, token, label) {
|
||||
const responses = await Promise.all([
|
||||
list(gameId),
|
||||
mine(gameId, token),
|
||||
save(gameId, token, { score: 5 }),
|
||||
]);
|
||||
check(
|
||||
`${label}:公共/个人/保存均 404`,
|
||||
responses.every((row) => row.status === 404),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${apiBase}/healthz`);
|
||||
check('真实 API 健康', health.ok);
|
||||
const users = [];
|
||||
const stamp = Date.now();
|
||||
for (let index = 0; index < 21; index += 1)
|
||||
users.push(await account(index, stamp));
|
||||
const [author, other] = users;
|
||||
const gameId = await createGame(author, '评价验收主游戏');
|
||||
const secondGame = await createGame(author, '评价验收第二游戏');
|
||||
const privateGame = await createGame(author, '评价验收未公开游戏');
|
||||
await publish(gameId, author, 0);
|
||||
await publish(secondGame, author, 0);
|
||||
const empty = await list(gameId);
|
||||
check(
|
||||
'匿名空列表与 null/0 统计',
|
||||
empty.status === 200 &&
|
||||
empty.data.reviews.length === 0 &&
|
||||
empty.data.totalPages === 0 &&
|
||||
empty.data.ratingSummary.averageScore === null &&
|
||||
empty.data.ratingSummary.ratingCount === 0,
|
||||
);
|
||||
check('公开响应 no-store', empty.headers.get('cache-control') === 'no-store');
|
||||
check(
|
||||
'已登录未评价返回 null',
|
||||
(await mine(gameId, author.token)).data.review === null,
|
||||
);
|
||||
check('匿名个人读取 401', (await mine(gameId)).status === 401);
|
||||
check(
|
||||
'匿名写入 401',
|
||||
(await save(gameId, undefined, { score: 5 })).status === 401,
|
||||
);
|
||||
check(
|
||||
'失效认证 401',
|
||||
(await save(gameId, 'invalid-token', { score: 5 })).status === 401,
|
||||
);
|
||||
await invisible(privateGame, author.token, '未公开游戏');
|
||||
for (const [label, body, status] of [
|
||||
['非整数', { score: 1.5 }, 400],
|
||||
['0 分', { score: 0 }, 422],
|
||||
['4001 emoji', { score: 5, comment: '😀'.repeat(4001) }, 422],
|
||||
])
|
||||
check(
|
||||
`${label}拒绝 ${status}`,
|
||||
(await save(gameId, author.token, body)).status === status,
|
||||
);
|
||||
check('无效输入没有写入', (await list(gameId)).data.total === 0);
|
||||
const concurrent = await Promise.all(
|
||||
Array.from({ length: 6 }, () => save(gameId, author.token, { score: 1 })),
|
||||
);
|
||||
check(
|
||||
'并发首次保存均成功且同一 ID',
|
||||
concurrent.every(
|
||||
(row) =>
|
||||
row.status === 200 &&
|
||||
row.data.review.id === concurrent[0].data.review.id,
|
||||
),
|
||||
);
|
||||
const original = concurrent[0].data.review;
|
||||
check('1 分与省略评论成功', original.score === 1 && original.comment === '');
|
||||
check('并发只有一人', (await list(gameId)).data.total === 1);
|
||||
const repeated = await save(gameId, author.token, { score: 1, comment: '' });
|
||||
check(
|
||||
'相同内容重试不制造修改时间',
|
||||
repeated.data.review.updatedAt === original.updatedAt &&
|
||||
repeated.data.review.createdAt === original.createdAt,
|
||||
);
|
||||
const ten = await save(gameId, other.token, { score: 10, comment: '' });
|
||||
check(
|
||||
'10 分与空评论成功,全量均分 5.5',
|
||||
ten.status === 200 &&
|
||||
ten.data.ratingSummary.averageScore === 5.5 &&
|
||||
ten.data.ratingSummary.ratingCount === 2,
|
||||
);
|
||||
const long = await save(gameId, author.token, {
|
||||
score: 1,
|
||||
comment: '😀'.repeat(4000),
|
||||
});
|
||||
check(
|
||||
'4000 Unicode 码点保存完整',
|
||||
long.status === 200 && [...long.data.review.comment].length === 4000,
|
||||
);
|
||||
const text = ' 中文 English 😀 \r\n第二行\r末尾 ';
|
||||
const normal = await save(gameId, author.token, { score: 1, comment: text });
|
||||
check(
|
||||
'保留空格并归一化 CRLF/CR',
|
||||
normal.data.review.comment === text.replace(/\r\n?/gu, '\n'),
|
||||
);
|
||||
const blank = await save(gameId, author.token, {
|
||||
score: 1,
|
||||
comment: '\r\n \t',
|
||||
});
|
||||
check('纯空白保存为空', blank.data.review.comment === '');
|
||||
const forged = await save(gameId, author.token, {
|
||||
score: 3,
|
||||
comment: '作者更新',
|
||||
userId: other.user.id,
|
||||
reviewId: ten.data.review.id,
|
||||
author: { id: other.user.id, name: '伪造昵称' },
|
||||
});
|
||||
check(
|
||||
'伪造身份不修改他人',
|
||||
[200, 400].includes(forged.status) &&
|
||||
(await mine(gameId, other.token)).data.review.score === 10,
|
||||
);
|
||||
const second = await save(secondGame, author.token, { score: 8 });
|
||||
check(
|
||||
'同账号不同游戏独立',
|
||||
second.status === 200 &&
|
||||
second.data.ratingSummary.ratingCount === 1 &&
|
||||
second.data.review.id !== original.id,
|
||||
);
|
||||
for (let index = 2; index < users.length; index += 1) {
|
||||
const saved = await save(gameId, users[index].token, {
|
||||
score: (index % 10) + 1,
|
||||
comment: index % 2 ? '' : `评价 ${index}`,
|
||||
});
|
||||
assert(saved.status === 200, `评价 ${index} 保存失败 ${saved.status}`);
|
||||
}
|
||||
const firstPage = await list(gameId);
|
||||
const lastPage = await list(gameId, '?page=2');
|
||||
const rows = [...firstPage.data.reviews, ...lastPage.data.reviews];
|
||||
check(
|
||||
'21 条真实评价,两页 20/1',
|
||||
firstPage.data.total === 21 &&
|
||||
firstPage.data.pageSize === 20 &&
|
||||
firstPage.data.reviews.length === 20 &&
|
||||
lastPage.data.reviews.length === 1 &&
|
||||
firstPage.data.totalPages === 2,
|
||||
);
|
||||
check(
|
||||
'公共列表无重复并包含自己的评价',
|
||||
new Set(rows.map((row) => row.id)).size === 21 &&
|
||||
rows.filter((row) => row.id === original.id).length === 1,
|
||||
);
|
||||
const sorted = [...rows].sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.createdAt) - Date.parse(a.createdAt) ||
|
||||
b.id.localeCompare(a.id),
|
||||
);
|
||||
check(
|
||||
'创建时间倒序与稳定 ID 次序',
|
||||
rows.every((row, index) => row.id === sorted[index].id),
|
||||
);
|
||||
const average =
|
||||
Math.round(
|
||||
(rows.reduce((sum, row) => sum + row.score, 0) / rows.length) * 10,
|
||||
) / 10;
|
||||
check(
|
||||
'均分覆盖全部页',
|
||||
firstPage.data.ratingSummary.averageScore === average &&
|
||||
lastPage.data.ratingSummary.averageScore === average &&
|
||||
firstPage.data.ratingSummary.ratingCount === 21,
|
||||
);
|
||||
const before = (await mine(gameId, author.token)).data.review;
|
||||
const edited = await save(gameId, author.token, {
|
||||
score: 9,
|
||||
comment: '改分',
|
||||
});
|
||||
check(
|
||||
'修改稳定 ID/创建时间/人数',
|
||||
edited.data.review.id === before.id &&
|
||||
edited.data.review.createdAt === before.createdAt &&
|
||||
edited.data.ratingSummary.ratingCount === 21 &&
|
||||
edited.data.review.updatedAt !== before.updatedAt,
|
||||
);
|
||||
const expectedAverage =
|
||||
Math.round(
|
||||
((rows.reduce((sum, row) => sum + row.score, 0) - before.score + 9) /
|
||||
21) *
|
||||
10,
|
||||
) / 10;
|
||||
check(
|
||||
'改分重新计算均分',
|
||||
edited.data.ratingSummary.averageScore === expectedAverage,
|
||||
);
|
||||
const commentEdit = await save(gameId, author.token, {
|
||||
score: 9,
|
||||
comment: '<script>alert(1)</script>\n**纯文本**',
|
||||
});
|
||||
check(
|
||||
'只改评论不改变均分并保持纯文本',
|
||||
commentEdit.data.ratingSummary.averageScore === expectedAverage &&
|
||||
commentEdit.data.review.comment.startsWith('<script>'),
|
||||
);
|
||||
const afterRows = [
|
||||
...(await list(gameId)).data.reviews,
|
||||
...(await list(gameId, '?page=2')).data.reviews,
|
||||
];
|
||||
check(
|
||||
'编辑不改变排序位置',
|
||||
afterRows.every((row, index) => row.id === rows[index].id),
|
||||
);
|
||||
check(
|
||||
'超出末页是空列表',
|
||||
(await list(gameId, '?page=3')).data.reviews.length === 0,
|
||||
);
|
||||
check(
|
||||
'pageSize=1/50 边界',
|
||||
(await list(gameId, '?pageSize=1')).data.reviews.length === 1 &&
|
||||
(await list(gameId, '?pageSize=50')).data.reviews.length === 21,
|
||||
);
|
||||
for (const query of ['?page=0', '?pageSize=51']) {
|
||||
check(
|
||||
`非法分页 ${query} 返回 400`,
|
||||
(await list(gameId, query)).status === 400,
|
||||
);
|
||||
}
|
||||
const personal = await mine(gameId, author.token);
|
||||
check(
|
||||
'个人响应 no-store 与公共同一形状',
|
||||
personal.headers.get('cache-control') === 'no-store' &&
|
||||
personal.data.review.id === original.id &&
|
||||
personal.data.review.author.id === author.user.id,
|
||||
);
|
||||
await publish(gameId, author, 1);
|
||||
check(
|
||||
'发布新版本保留评价',
|
||||
(await list(gameId)).data.total === 21 &&
|
||||
(await mine(gameId, author.token)).data.review.id === original.id,
|
||||
);
|
||||
await procedure('unpublish_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
owner_user_id: author.user.id,
|
||||
expected_publication_revision: 2,
|
||||
...receipt(),
|
||||
});
|
||||
await invisible(gameId, author.token, '作者下架');
|
||||
await publish(gameId, author, 3);
|
||||
check('重新公开恢复已有评价', (await list(gameId)).data.total === 21);
|
||||
await procedure('suspend_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: 4,
|
||||
reason: [0, '评价验收'],
|
||||
...receipt(),
|
||||
});
|
||||
await invisible(gameId, author.token, '管理员封禁');
|
||||
await procedure('restore_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: 5,
|
||||
...receipt(),
|
||||
});
|
||||
check(
|
||||
'解除封禁保留评价并可编辑',
|
||||
(await list(gameId)).data.total === 21 &&
|
||||
(await save(gameId, author.token, { score: 9, comment: '恢复后编辑' }))
|
||||
.status === 200,
|
||||
);
|
||||
writeFileSync(
|
||||
resolve('.app/reviews-e2e-browser.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
database,
|
||||
apiBase,
|
||||
gameId,
|
||||
secondGame,
|
||||
accounts: [author, other].map(({ phone }) => ({
|
||||
phone,
|
||||
password: 'ReviewsE2e123!',
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
console.log(
|
||||
`全部 ${checks} 项通过;数据库=${database};主游戏=${gameId};第二游戏=${secondGame}。fixture 保留在隔离库,未上传 OSS。`,
|
||||
);
|
||||
}
|
||||
await main().catch((error) => {
|
||||
console.error(`FAIL ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user