300 lines
17 KiB
JavaScript
300 lines
17 KiB
JavaScript
// 问鼎 · 重要证明 —— 单测。钉三条铁律,一条都不许松:
|
||
// ① 递交=待审核,【对定品零影响】(自评上传绝不自动生效);
|
||
// ② 只有【超管审核 verified】才进 effortBonus(用户不能自评生效·防冒充);
|
||
// ③ 生效力度(level)由审核给,不认自评 claimLevel。
|
||
// 另钉:属主隔离(IDOR)、超管门(非超管审核=403)、防刷(条数上限)、撤回只能撤没审的、零游戏味文案墙。
|
||
//
|
||
// 纯函数层恒跑(verifiedLevel/effortBonus/office5d 效果);DB 集成层需本地 MongoDB,连不上整组 skip。
|
||
process.env.NODE_ENV = 'test';
|
||
process.env.MONGO_DB = process.env.MONGO_DB_TEST || 'digital_me_test';
|
||
|
||
import { test, before, after } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import express from 'express';
|
||
import http from 'node:http';
|
||
|
||
import { verifiedLevel, MAX_ITEMS, parseVerdict, verifyZhengming } from '../src/services/zhengming.js';
|
||
import { effortBonus, computeOffice5D, zhengmingRating } from '../src/office5d.js';
|
||
import { computeAxes } from '../src/dynasties.js';
|
||
import { subsFromAttrs } from '../src/attributes.js';
|
||
import { assertNoGameMechanics } from '../src/decisions.js';
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 纯函数层(无 DB,恒跑)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
test('铁律③ verifiedLevel:只认 status===verified,取已验证里最高 level', () => {
|
||
assert.equal(verifiedLevel(null), 0, '空 twin → 0');
|
||
assert.equal(verifiedLevel({}), 0, '无 zhengming → 0');
|
||
assert.equal(verifiedLevel({ zhengming: [] }), 0, '空数组 → 0');
|
||
// 待审核/驳回/自评 level 一律不算 —— 这是防刷的全部
|
||
assert.equal(verifiedLevel({ zhengming: [{ status: 'pending', level: 3 }] }), 0, '待审核 level 不算');
|
||
assert.equal(verifiedLevel({ zhengming: [{ status: 'rejected', level: 3 }] }), 0, '驳回不算');
|
||
// 只有 verified 才算,取最高
|
||
assert.equal(verifiedLevel({ zhengming: [
|
||
{ status: 'verified', level: 1 },
|
||
{ status: 'verified', level: 3 },
|
||
{ status: 'pending', level: 3 },
|
||
] }), 3, '取已验证里最高');
|
||
// level 越界夹紧
|
||
assert.equal(verifiedLevel({ zhengming: [{ status: 'verified', level: 99 }] }), 3, 'level 上限 3');
|
||
assert.equal(verifiedLevel({ zhengming: [{ status: 'verified', level: -5 }] }), 0, 'level 下限 0');
|
||
});
|
||
|
||
test('问鼎独立·zhengmingRating:已核验证明 0..3【自成一维,不再进改命 effortBonus】', () => {
|
||
// ★ 2026-07-24 拆维后:改命(effortBonus)=打卡/王/劳/知识,【不含证明】
|
||
assert.equal(effortBonus({ zhengming: 3 }), 0, '问鼎不再进改命 effortBonus');
|
||
assert.equal(effortBonus({ gaimingPoints: 6 }), 2, '改命·打卡档满 +2');
|
||
assert.equal(effortBonus({ gaimingPoints: 6, lao: 30 }), 3, '改命仍由打卡+熬时间等驱动,满 3');
|
||
// zhengmingRating:只认核定 level 0..3(自评/待审核=0,同 verifiedLevel 口径)
|
||
assert.equal(zhengmingRating({}), 0, '无 → 0');
|
||
assert.equal(zhengmingRating({ zhengming: 1 }), 1);
|
||
assert.equal(zhengmingRating({ zhengming: 3 }), 3);
|
||
assert.equal(zhengmingRating({ zhengming: 99 }), 3, '上限 3');
|
||
assert.equal(zhengmingRating({ zhengming: -5 }), 0, '下限 0');
|
||
});
|
||
|
||
test('问鼎独立·office5d:证明抬【问鼎(zheng)】这一维、不动改命;★无死维:改命满了问鼎照样推得动 tier', () => {
|
||
const base = {
|
||
professionLei: 'wen',
|
||
axes: computeAxes(subsFromAttrs(['poetic', 'story', 'vivid'], ['warm'])),
|
||
credentials: { zhicheng: 2 }, jiashi: 1, wang: 0, lao: 0, knowledge: 0,
|
||
};
|
||
const without = computeOffice5D({ ...base, zhengming: 0 }, 'tang');
|
||
const withZ = computeOffice5D({ ...base, zhengming: 3 }, 'tang');
|
||
// 问鼎(zheng)品第:有证明的更高;★ 改命(gai)品第不受它影响(已拆开、不再共用)
|
||
assert.ok(withZ.parts.pins.zheng.rank > without.parts.pins.zheng.rank, '证明抬高问鼎品第');
|
||
assert.equal(withZ.parts.pins.zheng.rank, 3);
|
||
assert.equal(without.parts.pins.zheng.rank, 0);
|
||
assert.equal(withZ.parts.pins.gai.rank, without.parts.pins.gai.rank, '证明不再动改命品第(已独立成维)');
|
||
// parts.zhengming 顶层仍在(level + 是否顶到宗师顶,供前端标「照见巅峰」)
|
||
assert.equal(withZ.parts.zhengming.level, 3);
|
||
assert.equal(typeof withZ.parts.zhengming.atCeiling, 'boolean');
|
||
// tier 单调不降 + 不越宗师顶
|
||
assert.ok(withZ.rank.tier >= without.rank.tier, 'tier 单调:证明只会抬不会降');
|
||
assert.ok(withZ.rank.tier <= withZ.parts.ceilTier, '证明不越宗师顶');
|
||
// ★★ 无死维核心:改命打卡已满(gaimingPoints=6)时,问鼎 0→3 仍能把 tier 往上推 —— 修掉「改命满则问鼎死」。
|
||
const dc = { professionLei: 'wen', axes: computeAxes(subsFromAttrs(['poetic'], ['warm'])), credentials: { zhicheng: 2 }, jiashi: 1, gaimingPoints: 6 };
|
||
const dcNoZ = computeOffice5D({ ...dc, zhengming: 0 }, 'tang');
|
||
const dcZ = computeOffice5D({ ...dc, zhengming: 3 }, 'tang');
|
||
assert.ok(dcZ.rank.tier > dcNoZ.rank.tier, '改命满时问鼎(lv3)仍推得动 tier(问鼎非死维)');
|
||
});
|
||
|
||
test('分身核验·parseVerdict:抠得出裁决取之,抠不出/形状不对一律 null(留 pending·不错认)', () => {
|
||
// 查实
|
||
assert.deepEqual(parseVerdict('{"verified":true,"level":3,"reason":"官网可查"}'), { verified: true, level: 3, reason: '官网可查' });
|
||
// 查否 → level 归 0
|
||
assert.deepEqual(parseVerdict('{"verified":false,"level":3,"reason":"查不到"}'), { verified: false, level: 0, reason: '查不到' });
|
||
// LLM 裹了自然语言也能抠出第一个 JSON
|
||
assert.equal(parseVerdict('好的,我核实了:{"verified":true,"level":1,"reason":"x"} 以上')?.verified, true);
|
||
// verified 缺失/非布尔 → null(不下结论)
|
||
assert.equal(parseVerdict('{"level":3}'), null);
|
||
assert.equal(parseVerdict('{"verified":"yes"}'), null);
|
||
// 抠不出 JSON / 空 → null
|
||
assert.equal(parseVerdict('查不到相关信息'), null);
|
||
assert.equal(parseVerdict(''), null);
|
||
assert.equal(parseVerdict(null), null);
|
||
// verified:true 但 level 越界 → 夹到 [1,3]
|
||
assert.equal(parseVerdict('{"verified":true,"level":99}')?.level, 3);
|
||
assert.equal(parseVerdict('{"verified":true,"level":0}')?.level, 1);
|
||
});
|
||
|
||
test('零游戏味:office5d 含证明的输出不带禁词(养成/解锁/成就/进度/百分比…)', () => {
|
||
const r = computeOffice5D({
|
||
professionLei: 'wen',
|
||
axes: computeAxes(subsFromAttrs(['poetic', 'story'], ['warm'])),
|
||
credentials: {}, jiashi: 0, zhengming: 3,
|
||
}, 'tang');
|
||
assertNoGameMechanics(JSON.stringify(r)); // 复用全站那一份禁词清单,不另写
|
||
});
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// DB 集成层(需本地 MongoDB;连不上整组 skip)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
let server, base, db, closeDb, ObjectId, signToken, config;
|
||
const OWNER = { id: null, email: 'zm-owner@test.local' };
|
||
const OTHER = { id: null, email: 'zm-other@test.local' };
|
||
const ADMIN = { id: null, email: 'zm-admin@test.local' };
|
||
let twinId, otherTwinId, mongoUp = true;
|
||
const CLERK = ['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'];
|
||
|
||
before(async () => {
|
||
const dbm = await import('../src/db.js');
|
||
closeDb = dbm.closeDb;
|
||
try { db = await dbm.connectDb(); }
|
||
catch { mongoUp = false; return; }
|
||
({ ObjectId } = await import('mongodb'));
|
||
({ signToken } = await import('../src/middleware/auth.js'));
|
||
({ config } = await import('../src/config.js'));
|
||
// ★ 造一个超管(审核台要它):直接往 config 里塞(isSuperAdmin 调用时才读)。
|
||
if (!config.security.superAdmins.includes(ADMIN.email)) config.security.superAdmins.push(ADMIN.email);
|
||
const { shishuRouter } = await import('../src/routes/shishu.js');
|
||
const { adminRouter } = await import('../src/routes/admin.js');
|
||
|
||
OWNER.id = new ObjectId(); OTHER.id = new ObjectId(); ADMIN.id = new ObjectId();
|
||
await db.collection('users').insertMany([
|
||
{ _id: OWNER.id, email: OWNER.email },
|
||
{ _id: OTHER.id, email: OTHER.email },
|
||
{ _id: ADMIN.id, email: ADMIN.email },
|
||
]);
|
||
const t = await db.collection('twins').insertOne({
|
||
ownerId: OWNER.id, displayName: '某', profession: '', talents: { sub_talents: CLERK }, isPublic: false,
|
||
});
|
||
twinId = t.insertedId;
|
||
const t2 = await db.collection('twins').insertOne({
|
||
ownerId: OTHER.id, displayName: '他', talents: { sub_talents: CLERK }, isPublic: false,
|
||
});
|
||
otherTwinId = t2.insertedId;
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/shishu', shishuRouter);
|
||
app.use('/api/admin', adminRouter);
|
||
server = http.createServer(app).listen(0);
|
||
await new Promise((ok) => server.once('listening', ok));
|
||
base = `http://127.0.0.1:${server.address().port}`;
|
||
});
|
||
|
||
after(async () => {
|
||
if (server) server.close();
|
||
if (mongoUp) {
|
||
await db.collection('twins').deleteMany({ _id: { $in: [twinId, otherTwinId] } });
|
||
await db.collection('users').deleteMany({ _id: { $in: [OWNER.id, OTHER.id, ADMIN.id] } });
|
||
if (closeDb) await closeDb();
|
||
}
|
||
});
|
||
|
||
const auth = (u = OWNER) => ({ authorization: `Bearer ${signToken({ id: u.id.toString(), email: u.email })}` });
|
||
const get = (p, u) => fetch(base + p, { headers: auth(u) });
|
||
const post = (p, body, u) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...auth(u) }, body: JSON.stringify(body || {}) });
|
||
const del = (p, u) => fetch(base + p, { method: 'DELETE', headers: auth(u) });
|
||
const resetZM = () => db.collection('twins').updateOne({ _id: twinId }, { $set: { zhengming: [] } });
|
||
|
||
test('铁律① 递交=待审核,对定品零影响;审核通过后才生效', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
await resetZM();
|
||
// 递交一条
|
||
const r = await post(`/api/shishu/zhengming/${twinId}`, { title: '某国家级奖项', domain: 'wen', claimLevel: 3, note: '证书编号 X' });
|
||
assert.equal(r.status, 200);
|
||
const rec = await r.json();
|
||
assert.equal(rec.status, 'pending', '落库即待审核');
|
||
assert.equal(rec.level, 0, '待审核 level=0(对定品零影响)');
|
||
// 此刻 verifiedLevel 仍是 0 —— 定品不受影响
|
||
let twin = await db.collection('twins').findOne({ _id: twinId });
|
||
assert.equal(verifiedLevel(twin), 0, '未审核 → verifiedLevel 0');
|
||
|
||
// 超管审核通过,核定 level 2
|
||
const rv = await post(`/api/admin/zhengming/${twinId}/${rec.id}/review`, { status: 'verified', level: 2 }, ADMIN);
|
||
assert.equal(rv.status, 200);
|
||
twin = await db.collection('twins').findOne({ _id: twinId });
|
||
assert.equal(verifiedLevel(twin), 2, '审核通过后 verifiedLevel=2(这一刻才生效)');
|
||
});
|
||
|
||
test('铁律② 超管门:非超管不能审核(403)', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
await resetZM();
|
||
const r = await post(`/api/shishu/zhengming/${twinId}`, { title: 'A' });
|
||
const rec = await r.json();
|
||
// 分身主人自己审 → 403(不能自评生效)
|
||
const self = await post(`/api/admin/zhengming/${twinId}/${rec.id}/review`, { status: 'verified', level: 3 }, OWNER);
|
||
assert.equal(self.status, 403, '本人不能自审');
|
||
// 陌生人审 → 403
|
||
const other = await post(`/api/admin/zhengming/${twinId}/${rec.id}/review`, { status: 'verified', level: 3 }, OTHER);
|
||
assert.equal(other.status, 403);
|
||
// 审核后仍是 pending(没被非法改动)
|
||
const twin = await db.collection('twins').findOne({ _id: twinId });
|
||
assert.equal(twin.zhengming[0].status, 'pending');
|
||
assert.equal(verifiedLevel(twin), 0);
|
||
});
|
||
|
||
test('属主隔离(IDOR):拿不到/递交不了别人的分身', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
// OTHER 递交到 OWNER 的分身 → 404
|
||
const r = await post(`/api/shishu/zhengming/${twinId}`, { title: '偷' }, OTHER);
|
||
assert.equal(r.status, 404, '别人的分身递交不了');
|
||
// OTHER 列 OWNER 的分身 → 404
|
||
const l = await get(`/api/shishu/zhengming/${twinId}`, OTHER);
|
||
assert.equal(l.status, 404, '别人的分身列不了');
|
||
});
|
||
|
||
test('防刷:条数到上限拒收(409)', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
// 直接塞满到上限
|
||
const full = Array.from({ length: MAX_ITEMS }, (_, i) => ({ id: 'x' + i, title: 't', status: 'pending', level: 0 }));
|
||
await db.collection('twins').updateOne({ _id: twinId }, { $set: { zhengming: full } });
|
||
const r = await post(`/api/shishu/zhengming/${twinId}`, { title: '再来一条' });
|
||
assert.equal(r.status, 409, '到上限拒收');
|
||
});
|
||
|
||
test('撤回:只能撤没审的;审过的撤不动', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
await resetZM();
|
||
const p = await (await post(`/api/shishu/zhengming/${twinId}`, { title: '待审的' })).json();
|
||
const v = await (await post(`/api/shishu/zhengming/${twinId}`, { title: '将审的' })).json();
|
||
await post(`/api/admin/zhengming/${twinId}/${v.id}/review`, { status: 'verified', level: 1 }, ADMIN);
|
||
// 撤待审的 → 掉
|
||
const d1 = await del(`/api/shishu/zhengming/${twinId}/${p.id}`);
|
||
assert.equal(d1.status, 200);
|
||
// 撤已审的 → 撤不动(仍在)
|
||
await del(`/api/shishu/zhengming/${twinId}/${v.id}`);
|
||
const twin = await db.collection('twins').findOne({ _id: twinId });
|
||
const ids = (twin.zhengming || []).map((z) => z.id);
|
||
assert.ok(!ids.includes(p.id), '待审的已撤走');
|
||
assert.ok(ids.includes(v.id), '已审的撤不动、留痕');
|
||
});
|
||
|
||
test('分身核验·查实就认(自动):mock 查实→verified+level+by:ai;查否→rejected;不下结论→留 pending', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
await resetZM();
|
||
// 递交三条(注意:路由里 submit 会 detach 触发真 llmVerify,但测试环境无 DASHSCOPE key → 留 pending,
|
||
// 不影响;这里用【注入 mock verifier】直接、确定性地测编排。)
|
||
const a = await (await post(`/api/shishu/zhengming/${twinId}`, { title: '查得到的顶尖奖' })).json();
|
||
const b = await (await post(`/api/shishu/zhengming/${twinId}`, { title: '查不到的' })).json();
|
||
const c = await (await post(`/api/shishu/zhengming/${twinId}`, { title: '一时查不准的' })).json();
|
||
|
||
// 查实 → verified + level + by:'ai'
|
||
await verifyZhengming(twinId, a.id, async () => ({ verified: true, level: 3, reason: '官网可查' }));
|
||
// 查否 → rejected
|
||
await verifyZhengming(twinId, b.id, async () => ({ verified: false, level: 0, reason: '同名不同人' }));
|
||
// 不下结论(null) → 保持 pending
|
||
await verifyZhengming(twinId, c.id, async () => null);
|
||
|
||
const twin = await db.collection('twins').findOne({ _id: twinId });
|
||
const byId = Object.fromEntries((twin.zhengming || []).map((z) => [z.id, z]));
|
||
assert.equal(byId[a.id].status, 'verified'); assert.equal(byId[a.id].level, 3); assert.equal(byId[a.id].by, 'ai');
|
||
assert.equal(byId[b.id].status, 'rejected'); assert.equal(byId[b.id].level, 0);
|
||
assert.equal(byId[c.id].status, 'pending', '不下结论 → 留 pending,等重核/人工兜底');
|
||
// 生效:已查实的 level 3 进 verifiedLevel
|
||
assert.equal(verifiedLevel(twin), 3);
|
||
|
||
// 幂等:对已 verified 的再核,不改(skipped)
|
||
const again = await verifyZhengming(twinId, a.id, async () => ({ verified: false, level: 0 }));
|
||
assert.ok(again.skipped, '已核过的不再动');
|
||
const twin2 = await db.collection('twins').findOne({ _id: twinId });
|
||
assert.equal(twin2.zhengming.find((z) => z.id === a.id).status, 'verified', '幂等:仍是 verified');
|
||
});
|
||
|
||
test('分身核验·reverify 路由:卡在 pending 的可再核一次(属主)', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
await resetZM();
|
||
const rec = await (await post(`/api/shishu/zhengming/${twinId}`, { title: '再核测试' })).json();
|
||
// 无 DASHSCOPE key → 真 llmVerify 留 pending;reverify 路由应 200(走真核验,仍 pending)
|
||
const r = await post(`/api/shishu/zhengming/${twinId}/${rec.id}/reverify`, {});
|
||
assert.equal(r.status, 200);
|
||
// 别人的分身重核不了
|
||
const other = await post(`/api/shishu/zhengming/${twinId}/${rec.id}/reverify`, {}, OTHER);
|
||
assert.equal(other.status, 404);
|
||
});
|
||
|
||
test('审核台:列出待审 + 递交需标题 + note 上限', async (t) => {
|
||
if (!mongoUp) return t.skip('无本地 MongoDB');
|
||
await resetZM();
|
||
// 无标题 → 400
|
||
const noTitle = await post(`/api/shishu/zhengming/${twinId}`, { note: '只有说明' });
|
||
assert.equal(noTitle.status, 400);
|
||
// 递交一条 → 出现在超管待审台
|
||
await post(`/api/shishu/zhengming/${twinId}`, { title: '待审台可见' });
|
||
const pend = await (await get('/api/admin/zhengming/pending', ADMIN)).json();
|
||
assert.ok(pend.items.some((x) => x.twinId === twinId.toString() && x.title === '待审台可见'), '待审台列出这一条');
|
||
});
|