b60aea4e71
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>
477 lines
16 KiB
JavaScript
477 lines
16 KiB
JavaScript
// 网站游戏评价真实 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;
|
|
});
|