// 网站游戏评价真实 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: '\n**纯文本**', }); check( '只改评论不改变均分并保持纯文本', commentEdit.data.ratingSummary.averageScore === expectedAverage && commentEdit.data.review.comment.startsWith('