// 当前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, 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; assert( database === state.database && database.includes('reviews-e2e'), '必须显式指定当前 reviews-e2e 隔离数据库', ); const base = state.services['api-server'].url; assert( ['127.0.0.1', 'localhost'].includes(new URL(base).hostname), '只允许本机API', ); 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 = fixture && `/api/game-distribution/games/${fixture.gameId}`; let checks = 0; function check(label, ok) { assert(ok, label); checks += 1; console.log(`PASS ${label}`); } async function api(path, { token, method = 'GET', body, key } = {}) { const headers = { 'x-genarrative-response-envelope': 'v1' }; if (token) headers.Authorization = `Bearer ${token}`; if (key !== undefined) headers['Idempotency-Key'] = key; if (body !== undefined) headers['Content-Type'] = 'application/json'; const response = await fetch(`${base}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000), }); const result = await response.json(); return { status: response.status, data: result.data, headers: response.headers, }; } async function login(account, admin = false) { const response = await api(admin ? '/admin/api/login' : '/api/auth/entry', { method: 'POST', body: admin ? account : { purePhoneNumber: account.phone, password: account.password }, }); assert( response.status === 200 && response.data.token, `登录fixture失败 ${response.status}`, ); return response.data; } const oldFields = (review) => Object.fromEntries( ['id', 'gameId', 'score', 'comment', 'createdAt', 'updatedAt'].map( (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( reviews.status === 200 && reviews.data.total >= 21, '先在旧schema运行ratings-e2e建立21条评价', ); assert( reviews.data.reviews.every((review) => !Object.hasOwn(review, 'isHidden')), '此模式只接受未追加isHidden的旧schema API', ); mkdirSync(dir, { recursive: true }); writeFileSync( snapshotPath, JSON.stringify( { database, gameId: fixture.gameId, reviews: reviews.data.reviews.map(oldFields), }, null, 2, ), ); console.log( `旧schema快照已保存:${reviews.data.total}条;快照仅在ignored文件。`, ); } async function migration() { const old = JSON.parse(readFileSync(snapshotPath, 'utf8')); const current = await api(`${publicPrefix}/reviews?pageSize=50`); check( '旧schema存量无损升级,默认公开false', current.status === 200 && current.data.total === old.reviews.length && old.reviews.every((review) => { const row = current.data.reviews.find( (value) => value.id === review.id, ); return ( row?.isHidden === false && JSON.stringify(oldFields(row)) === JSON.stringify(review) ); }), ); writeFileSync( resolve(dir, 'migration-verified.json'), JSON.stringify( { database, gameId: fixture.gameId, count: old.reviews.length, verifiedAt: new Date().toISOString(), }, null, 2, ), ); } async function run() { const health = await fetch(`${base}/healthz`); check('API健康', health.ok); assert( existsSync(credentialPath), '先运行prepare,并向API注入owner凭据后重启', ); const credentials = JSON.parse(readFileSync(credentialPath, 'utf8')); const ownerLogin = await login(credentials.owner, true); const owner = ownerLogin.token; for (const [name, tabs] of [ ['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, body: { ...credentials[name], displayName: `评价验收${name}`, tabPermissions: tabs, actionPermissions: [], enabled: true, }, }); 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; const userLogin = await login(fixture.accounts[0]); const user = userLogin.token; const userId = userLogin.user.id; const query = (suffix = '', token = allowed) => api(`${adminPrefix}/user-reviews${suffix}`, { token }); const myReview = () => api(`${publicPrefix}/my-review`, { token: user }); const save = (body) => api(`${publicPrefix}/my-review`, { token: user, method: 'PUT', body }); const publicReviews = () => api(`${publicPrefix}/reviews?pageSize=50`); const before = (await myReview()).data.review; const reviewPath = `${adminPrefix}/user-reviews/${encodeURIComponent(before.id)}`; const detail = () => api(reviewPath, { token: allowed }); const moderation = (body, key = randomUUID(), token = allowed) => api(`${reviewPath}/moderation`, { token, method: 'POST', key, body }); const body = (action, reason, expectedCreatedAt = before.createdAt) => ({ action, expectedCreatedAt, ...(reason !== undefined ? { reason } : {}), }); const listPath = '?gameId=' + encodeURIComponent(fixture.gameId); for (const path of [ '/user-reviews', '/user-review-games', `/user-reviews/${encodeURIComponent(before.id)}`, ]) { check(`匿名${path}401`, (await api(adminPrefix + path)).status === 401); check( `无权限${path}403`, (await api(adminPrefix + path, { token: denied })).status === 403, ); } check( '无权限写403', (await moderation(body('hide', '权限检查'), randomUUID(), denied)) .status === 403, ); check( '普通账号不能进入后台', [401, 403].includes((await query('', user)).status), ); const all = await query(listPath + '&pageSize=50'); check( '21+全量记录与no-store', all.status === 200 && 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与跨页', (await query(listPath)).data.reviews.length === 20 && page2.data.reviews.length >= 1, ); const target = page2.data.reviews[0]; check('组合查询目标原来在第二页', target.id === before.id); const keyword = `CrossPageNeedle[.*]${randomUUID()}`; const edited = await save({ score: before.score, comment: keyword }); assert(edited.status === 200); const combined = await query( `${listPath}&userId=${encodeURIComponent(userId)}&keyword=${encodeURIComponent(keyword)}&status=visible&pageSize=1`, ); check( '四条件AND在全量数据筛选,关键词按字面量', combined.data.total === 1 && combined.data.reviews[0].id === before.id, ); check( '区分大小写与空评论不命中', ( await query( listPath + '&keyword=' + encodeURIComponent(keyword.toLowerCase()), ) ).data.total === 0, ); const gameSearch = await api( `${adminPrefix}/user-review-games?query=${encodeURIComponent(fixture.gameId)}`, { token: allowed }, ); check( '游戏ID精确选择', gameSearch.status === 200 && gameSearch.data.games.some((game) => game.gameId === fixture.gameId), ); check( '详情no-store且正文完整', (await detail()).headers.get('cache-control') === 'no-store' && (await detail()).data.review.comment === keyword, ); for (const [label, request, key, expected] of [ ['隐藏空原因', body('hide', ' '), randomUUID(), 422], ['非法动作', body('unknown', 'x'), randomUUID(), 400], ['缺key', body('hide', 'x'), undefined, 400], ]) { const response = key === undefined ? await api(`${reviewPath}/moderation`, { token: allowed, method: 'POST', body: request, }) : await moderation(request, key); 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( { ...body('hide', ' 秘密管理原因 '), adminUserId: 'forged-admin' }, hideKey, ); check( '隐藏成功原因trim管理员来自会话', hidden.status === 200 && hidden.data.review.isHidden && hidden.data.operation.reason === '秘密管理原因' && hidden.data.operation.adminUserId === allowedLogin.admin.subject, ); check( '隐藏不改用户内容时间', hidden.data.review.createdAt === preHide.createdAt && hidden.data.review.updatedAt === preHide.updatedAt, ); const hiddenMine = (await myReview()).data.review; const hiddenPublic = await publicReviews(); check( '个人显示隐藏,公共排除统计', hiddenMine.isHidden && hiddenPublic.data.total === total - 1 && hiddenPublic.data.ratingSummary.ratingCount === total - 1 && !hiddenPublic.data.reviews.some((row) => row.id === before.id), ); const hiddenAverage = Math.round( (hiddenPublic.data.reviews.reduce((sum, row) => sum + row.score, 0) / hiddenPublic.data.total) * 10, ) / 10; check( '隐藏后均分基于公开全集', hiddenPublic.data.ratingSummary.averageScore === hiddenAverage, ); check( '普通DTO不泄漏管理原因或管理员', !JSON.stringify(hiddenMine).includes('秘密管理原因') && !Object.hasOwn(hiddenMine, 'operations') && !Object.hasOwn(hiddenMine, 'adminUserId'), ); const newContent = await save({ score: 10, comment: '隐藏后编辑', isHidden: false, adminUserId: 'forged', }); check( '用户编辑保留隐藏且摘要不计入', newContent.status === 200 && newContent.data.review.isHidden && newContent.data.ratingSummary.ratingCount === total - 1, ); check( '隐藏状态组合筛选', ( await query( listPath + '&status=hidden&userId=' + encodeURIComponent(userId), ) ).data.total === 1, ); check( '同key重试不重复日志', (await moderation(body('hide', '秘密管理原因'), hideKey)).data.replayed && (await detail()).data.operations.length === 1, ); check( '同key不同请求409', (await moderation(body('hide', '另一原因'), hideKey)).status === 409, ); const noOpKey = randomUUID(); const noOpHide = await moderation( body('hide', '重复隐藏也记录请求'), noOpKey, ); check( '已隐藏的新key请求仍记录操作', noOpHide.status === 200 && !noOpHide.data.replayed && (await detail()).data.operations.length === 2, ); const restored = await moderation(body('restore')); check( '恢复无原因,统计重新加入', restored.status === 200 && !restored.data.review.isHidden && restored.data.operation.reason === null && (await publicReviews()).data.total === total, ); const replayHide = await moderation(body('hide', '秘密管理原因'), hideKey); check( '旧hide key重放不重新隐藏并返回现状态', replayHide.data.replayed && replayHide.data.review.isHidden === false && (await myReview()).data.review.isHidden === false, ); const replayNoOp = await moderation( body('hide', '重复隐藏也记录请求'), noOpKey, ); check( '旧无变化操作key不重新执行', replayNoOp.data.replayed && replayNoOp.data.review.isHidden === false, ); const longReason = await moderation(body('hide', '😀'.repeat(4000))); check( '原因4000码点成功', longReason.status === 200 && [...longReason.data.operation.reason].length === 4000, ); await moderation(body('restore')); const deletionKey = randomUUID(); const deleted = await moderation(body('delete', '物理删除验收'), deletionKey); check( '删除成功且个人null', deleted.status === 200 && deleted.data.review === null && (await myReview()).data.review === null && (await detail()).status === 404, ); check( '删除后无目标新key404', (await moderation(body('hide', '已删除目标'))).status === 404, ); const recreated = await save({ score: 7, comment: '删除后重新评价' }); check( '删除后重建同唯一ID新创建时间', recreated.status === 200 && recreated.data.review.id === before.id && recreated.data.review.createdAt !== before.createdAt && !recreated.data.review.isHidden, ); check( '旧createdAt新操作409', (await moderation(body('delete', '旧页面'))).status === 409, ); const replayDelete = await moderation( body('delete', '物理删除验收'), deletionKey, ); check( '旧delete key重试不删新评价,返回旧实例null', replayDelete.status === 200 && replayDelete.data.replayed && replayDelete.data.review === null && (await myReview()).data.review.createdAt === recreated.data.review.createdAt, ); check( '当前实例详情不混旧日志', (await detail()).data.operations.length === 0, ); const logPath = '/admin/api/database/tables/game_distribution_review_moderation_log/rows?limit=100&search=' + encodeURIComponent(before.id); const retainedLogs = await api(logPath, { token: owner }); check( '删除操作记录独立留存且不复制评论', retainedLogs.status === 200 && retainedLogs.data.rows.some( (row) => row.cells.operation_id === deleted.data.operation.id, ) && !retainedLogs.data.columns.includes('comment'), ); check( '评价权限不越权读取私有通用表', (await api(logPath, { token: allowed })).status === 403, ); const recreatedBody = (action, reason) => body(action, reason, recreated.data.review.createdAt); await moderation(recreatedBody('hide', '浏览器隐藏提示验收')); check( '存量其他评价ID内容时间未改变', (await query(listPath + '&pageSize=50')).data.reviews .filter((row) => row.id !== before.id) .every((row) => { 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']) check(`错误筛选${suffix}400`, (await query(suffix)).status === 400); const privateGames = await api( `${adminPrefix}/user-review-games?pageSize=50`, { token: allowed }, ); check( '后台搜索覆盖未公开游戏', privateGames.data.games.some((game) => game.status === 'unpublished'), ); const secondPrefix = `/api/game-distribution/games/${fixture.secondGame}`; const secondMine = (await api(secondPrefix + '/my-review', { token: user })) .data.review; assert(secondMine); const secondModerationPath = `${adminPrefix}/user-reviews/${encodeURIComponent(secondMine.id)}/moderation`; const secondHide = await api(secondModerationPath, { token: allowed, method: 'POST', key: randomUUID(), body: { action: 'hide', expectedCreatedAt: secondMine.createdAt, reason: '仅有评价全部隐藏', }, }); const allHidden = await api(secondPrefix + '/reviews'); check( '全部隐藏为null/0且0页空列表', secondHide.status === 200 && allHidden.data.reviews.length === 0 && allHidden.data.totalPages === 0 && allHidden.data.ratingSummary.averageScore === null && allHidden.data.ratingSummary.ratingCount === 0, ); const secondGameDetail = await api(secondPrefix); const revision = secondGameDetail.data.publicationRevision; const suspend = await api( `${adminPrefix}/games/${fixture.secondGame}/suspend`, { token: owner, method: 'POST', key: randomUUID(), body: { expectedPublicationRevision: revision, reason: '后台不可见游戏管理验收', }, }, ); assert(suspend.status === 200); check( '暂停后网站个人与公开404', (await api(secondPrefix + '/my-review', { token: user })).status === 404 && (await api(secondPrefix + '/reviews')).status === 404, ); const secondAdmin = await query( '?gameId=' + encodeURIComponent(fixture.secondGame), ); check( '暂停游戏后台仍可读取隐藏评价', secondAdmin.status === 200 && secondAdmin.data.total === 1 && secondAdmin.data.reviews[0].game.status === 'suspended', ); const secondRestore = await api(secondModerationPath, { token: allowed, method: 'POST', key: randomUUID(), body: { action: 'restore', expectedCreatedAt: secondMine.createdAt }, }); check( '暂停游戏后台仍可恢复评价', secondRestore.status === 200 && !secondRestore.data.review.isHidden, ); await api(secondModerationPath, { token: allowed, method: 'POST', key: randomUUID(), body: { action: 'hide', expectedCreatedAt: secondMine.createdAt, reason: '验证游戏恢复不解除评价隐藏', }, }); const gameRestored = await api( `${adminPrefix}/games/${fixture.secondGame}/restore`, { token: owner, method: 'POST', key: randomUUID(), body: { expectedPublicationRevision: revision + 1 }, }, ); check( '恢复游戏不解除评价隐藏', gameRestored.status === 200 && (await api(secondPrefix + '/my-review', { token: user })).data.review .isHidden && (await api(secondPrefix + '/reviews')).data.total === 0, ); writeFileSync( resolve(dir, 'browser.json'), JSON.stringify( { database, gameId: fixture.gameId, apiBase: base, targetReviewId: before.id, accounts: fixture.accounts, admin: credentials, }, null, 2, ), { mode: 0o600 }, ); console.log( `全部${checks}项通过;browser fixture 已写入ignored .app/review-moderation-e2e/browser.json。`, ); } 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; });