// 集成测试:把历史 bug 全部重演为断言;零依赖,node >= 20 直接运行 // 覆盖:演示账号播种/幂等、登录、邀请码增删改查全矩阵、注册路径(静态码/动态码/大小写/拒绝)、 // 「重启后持久化且不重复播种」、管理员角色规则 import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import url from 'node:url'; const root = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..'); const SERVER = path.join(root, 'sync-server/src/index.mjs'); const INVITE = 'ci-static-code'; let tmp; try { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-it-')); } catch { tmp = path.join(root, '.tmp-test'); fs.rmSync(tmp, { recursive: true, force: true }); fs.mkdirSync(tmp, { recursive: true }); } let pass = 0, fail = 0; const failures = []; const ok = (cond, label, extra) => { if (cond) { pass++; console.log(' ✓ ' + label); } else { fail++; failures.push(label); console.log(' ✗ ' + label + (extra ? ' | ' + JSON.stringify(extra).slice(0, 220) : '')); } }; const api = (base, method, p, body, token) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: 'Bearer ' + token } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); async function startServer(port, phase) { // 每阶段新的模块实例(query 绕过 ESM 缓存)——等价于一次真实的进程重启 await import(url.pathToFileURL(SERVER).href + '?phase=' + phase); const base = 'http://127.0.0.1:' + port; for (let i = 0; i < 80; i++) { try { const r = await fetch(base + '/health'); if (r.ok) return base; } catch {} await new Promise((res) => setTimeout(res, 250)); } throw new Error('server did not come up on port ' + port); } // ---------- Phase A:全新库 ---------- const dataA = path.join(tmp, 'A/data'); fs.mkdirSync(dataA, { recursive: true }); process.env.DATA_DIR = dataA; process.env.INVITE_CODE = INVITE; process.env.MAX_USERS = '20'; process.env.PORT = '8891'; console.log('\n== Phase A:全新启动(播种/登录/邀请码全矩阵) =='); const A = await startServer(8891, 1); const demoLogin = await api(A, 'POST', '/auth/login', { username: 'demo', password: 'demo1234' }); ok(demoLogin.status === 200 && demoLogin.body?.data?.token, 'demo 登录', demoLogin.body); const D = demoLogin.body.data.token; // 首个真实用户 root 自动成为管理员(demo 不占席位) const rootReg = await api(A, 'POST', '/auth/register', { username: 'root', password: 'password123', invite_code: INVITE }); ok(rootReg.status === 200, '首个真实用户 root 注册', rootReg.body); const rootLogin = await api(A, 'POST', '/auth/login', { username: 'root', password: 'password123' }); ok(rootLogin.status === 200, 'root 登录', rootLogin.body); const T = rootLogin.body.data.token; const me = await api(A, 'GET', '/auth/me', undefined, D); ok(me.body?.data?.username === 'demo' && me.body?.data?.is_admin === false, 'demo 不是管理员(演示账号不占席位)', me.body); const rootMe = await api(A, 'GET', '/auth/me', undefined, T); ok(rootMe.body?.data?.username === 'root' && rootMe.body?.data?.is_admin === true, 'root 是管理员', rootMe.body); const fp = await api(A, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D); ok(fp.body?.data?.total === 25, '演示足迹 25 条(播种成功,含 10 项竞赛+证书附件)', fp.body?.data?.total); const grades = await api(A, 'POST', '/api/grades_overview', {}, D); ok(grades.body?.data?.semesters?.length === 4, '演示学期 4 个', grades.body?.data?.semesters?.length); const tl = await api(A, 'POST', '/api/timeline', { query: {} }, D); ok((tl.body?.data?.groups?.length ?? 0) >= 1, 'timeline 有分组'); const st = await api(A, 'POST', '/api/stats_overview', {}, D); ok(st.body?.ok === true, 'stats_overview 可用'); // 附件缩略图:上传带 thumb → 列表标记 → thumb/原图分离读取 const tl0 = await api(A, 'POST', '/api/type_list', {}, D); const fpUp = await api(A, 'POST', '/api/footprint_upsert', { item: { type_id: (tl0.body?.data?.items || [])[0]?.id, title: '附件缩略图测试', status: 'finished' } }, D); const fpId = fpUp.body?.data?.id; ok(!!fpId, '创建附件测试足迹', fpUp.body); const attAdd = await api(A, 'POST', '/api/attachment_add', { owner_id: fpId, file_name: 'photo.jpg', mime: 'image/jpeg', size: 8, data_b64: Buffer.from('ORIGDATA').toString('base64'), thumb_b64: Buffer.from('THUMBWEBPDATA').toString('base64') }, D); ok(attAdd.body?.ok === true && !!attAdd.body?.data?.item?.thumb_rel_path, 'attachment_add 存储缩略图', attAdd.body); const attList = await api(A, 'POST', '/api/attachment_list', { owner_id: fpId }, D); ok(!!attList.body?.data?.items?.[0]?.thumb_rel_path, 'attachment_list 返回缩略图标记'); const attThumb = await api(A, 'POST', '/api/attachment_thumb', { id: attAdd.body?.data?.item?.id }, D); ok(attThumb.body?.data?.data_b64 === Buffer.from('THUMBWEBPDATA').toString('base64'), 'attachment_thumb 返回缩略图内容', attThumb.body); const attRead2 = await api(A, 'POST', '/api/attachment_read', { id: attAdd.body?.data?.item?.id }, D); ok(attRead2.body?.data?.data_b64 === Buffer.from('ORIGDATA').toString('base64'), 'attachment_read 原图不受影响', attRead2.body); // 下载端点(带原始文件名) const dlAtt = await api(A, 'POST', '/api/attachment_download', { id: attAdd.body?.data?.item?.id }, D); const dlText = await (dlAdd0 => dlAdd0)(fetch(A + '/api/attachment_download', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + D }, body: JSON.stringify({ id: attAdd.body?.data?.item?.id }) }).then(async r => ({ s: r.status, ct: r.headers.get('content-type'), cd: r.headers.get('content-disposition'), t: await r.text() }))); ok(dlText.s === 200 && dlText.t === 'ORIGDATA' && /photo\.jpg/.test(dlText.cd), 'attachment_download 端点(文件名+内容)', { s: dlText.s, cd: dlText.cd, t: dlText.t.slice(0, 40) }); // PUT 原始二进制上传(新通道) const rawUp = await fetch(A + '/sync/attachment/raw?owner=' + fpId + '&name=raw.bin&mime=application/octet-stream', { method: 'PUT', headers: { authorization: 'Bearer ' + D, 'content-type': 'application/octet-stream' }, body: Buffer.from('RAWORIGDATA'), }).then(async r => ({ s: r.status, b: await r.json().catch(() => null) })); ok(rawUp.s === 200 && !!rawUp.b?.data?.item?.id, 'PUT raw 二进制上传', rawUp.body); const tSet = await api(A, 'POST', '/api/attachment_set_thumb', { id: rawUp.b?.data?.item?.id, thumb_b64: Buffer.from('RAWSUMB').toString('base64') }, D); ok(tSet.body?.ok === true, 'attachment_set_thumb 回填缩略图', tSet.body); const tGet = await api(A, 'POST', '/api/attachment_thumb', { id: rawUp.b?.data?.item?.id }, D); ok(tGet.body?.data?.data_b64 === Buffer.from('RAWSUMB').toString('base64'), 'attachment_thumb 读回 raw 缩略图', tGet.body); // ---- admin 用户删除级联 ---- const invDel = await api(A, 'POST', '/admin/invites/generate', { count: 1 }, T); const delCode = invDel.body?.data?.codes?.[0]; const regDel = await api(A, 'POST', '/auth/register', { username: 'deltgt', password: 'password123', invite_code: delCode }); ok(regDel.status === 200, 'deltgt 注册', regDel.body); const usersBefore = await api(A, 'GET', '/admin/users', undefined, T); const delUser = usersBefore.body?.data?.users?.find((u) => u.username === 'deltgt'); ok(!!delUser, 'deltgt 出现在用户列表', delUser); const selfUser = usersBefore.body?.data?.users?.find((u) => u.username === 'root'); const selfDel = await api(A, 'POST', '/admin/users/delete', { user_id: selfUser.id }, T); ok(selfDel.status !== 200, '不能删除自己(高危保护)', selfDel.status); const delResp = await api(A, 'POST', '/admin/users/delete', { user_id: delUser.id }, T); ok(delResp.status === 200, 'admin_user_delete 断言:删除成功', delResp.body); const usersAfter = await api(A, 'GET', '/admin/users', undefined, T); ok(!usersAfter.body?.data?.users?.some((u) => u.username === 'deltgt'), 'deltgt 已从列表移除'); ok(!fs.existsSync(path.join(dataA, 'users', delUser.id + '.db')), '用户库文件已删除'); ok(!fs.existsSync(path.join(dataA, 'users', String(delUser.id))), '用户附件目录已删除'); // 清理测试邀请码,恢复邀请码计数 const delInv = await api(A, 'GET', '/admin/invites', undefined, T); const invRow = delInv.body?.data?.invites?.find((i) => i.code === delCode); if (invRow) { await api(A, 'POST', '/admin/invites/delete', { id: invRow.id }, T); } // 个人信息:upsert → get 往返 const profileContent = { info: { name: '张三', school: '重庆城市科技学院', major: '网络工程' }, resume: { selfIntro: '热爱安全研究' } }; const pUp = await api(A, 'POST', '/api/profile_upsert', { content: profileContent }, T); ok(pUp.body?.ok === true, 'profile_upsert', pUp.body); const pGet = await api(A, 'POST', '/api/profile_get', {}, T); ok(pGet.body?.data?.content?.info?.name === '张三' && pGet.body?.data?.content?.resume?.selfIntro === '热爱安全研究', 'profile_get 往返一致', pGet.body); const pUp2 = await api(A, 'POST', '/api/profile_upsert', { content: { ...profileContent, info: { ...profileContent.info, name: '李四' } } }, T); const pGet2 = await api(A, 'POST', '/api/profile_get', {}, T); ok(pGet2.body?.data?.content?.info?.name === '李四', 'profile 单行更新语义', pGet2.body); // 删除整条记录 → 附件级联清理 const fpDel = await api(A, 'POST', '/api/footprint_delete', { id: fpId }, D); ok(fpDel.body?.ok === true, '删除整条记录'); const attAfter = await api(A, 'POST', '/api/attachment_list', { owner_id: fpId }, D); ok((attAfter.body?.data?.items || []).length === 0, '记录删除后附件级联清空', attAfter.body); // 邀请码:增 const c1 = await api(A, 'POST', '/admin/invites/create', { code: 'xiong-friend-2025', remark: '给学弟的码' }, T); ok(c1.status === 200 && c1.body?.data?.code === 'XIONG-FRIEND-2025', '新建自定义码(大小写归一化)', c1.body); ok((await api(A, 'POST', '/admin/invites/create', { code: 'xiong-friend-2025' }, T)).status === 400, '重复码被拒'); ok((await api(A, 'POST', '/admin/invites/create', { code: 'ab' }, T)).status === 400, '过短码被拒'); const gen = await api(A, 'POST', '/admin/invites/generate', { count: 3 }, T); ok((gen.body?.data?.codes ?? []).length === 3, '批量生成 3 个'); // 查 const list1 = (await api(A, 'GET', '/admin/invites', undefined, T)).body.data.invites; ok(list1.length === 4, '列表数量正确', list1.length); ok('remark' in list1[0] && 'created_by_name' in list1[0], '列表含 remark/created_by_name'); const target = list1.find((i) => i.code === 'XIONG-FRIEND-2025'); ok(target?.remark === '给学弟的码', '自定义码备注保存', target?.remark); // 改 ok((await api(A, 'POST', '/admin/invites/update', { id: target.id, remark: '改过的备注' }, T)).status === 200, '改备注'); ok((await api(A, 'POST', '/admin/invites/update', { id: target.id, valid: false }, T)).status === 200, '停用'); ok((await api(A, 'GET', '/admin/invites', undefined, T)).body.data.invites.find((i) => i.id === target.id).valid === false, '停用生效'); ok((await api(A, 'POST', '/auth/register', { username: 'friend1', password: 'password123', invite_code: 'xiong-friend-2025' })).status === 400, '停用码注册被拒'); ok((await api(A, 'POST', '/admin/invites/update', { id: target.id, valid: true }, T)).status === 200, '重新启用'); const regF = await api(A, 'POST', '/auth/register', { username: 'friend1', password: 'password123', invite_code: 'xiong-friend-2025' }); ok(regF.status === 200, '启用后注册成功', regF.body); ok((await api(A, 'POST', '/admin/invites/update', { id: target.id, valid: true }, T)).status === 400, '已使用的码不可再启用'); // 删 ok((await api(A, 'POST', '/admin/invites/delete', { id: list1[1].id }, T)).status === 200, '删除邀请码'); ok((await api(A, 'POST', '/admin/invites/delete', { id: list1[1].id }, T)).status === 404, '重复删除 404'); // 注册路径 ok((await api(A, 'POST', '/auth/register', { username: 'nocodeuser', password: 'password123' })).status === 400, '无邀请码注册被拒'); ok((await api(A, 'POST', '/auth/register', { username: 'badcode', password: 'password123', invite_code: 'WRONG-CODE' })).status === 400, '错误邀请码被拒'); // 幂等(管理端点) const reseed = await api(A, 'POST', '/admin/demo-seed', undefined, T); ok(reseed.body?.data?.seeded === false, '重复播种幂等跳过', reseed.body); // demo 重建路由(管理员):重置 demo 数据为初始播种态 const re2 = await api(A, 'POST', '/admin/demo-rebuild', {}, T); ok(re2.status === 200 && re2.body?.data?.seeded?.footprints === 25, 'demo 重建路由可达', re2.body); // ---------- Phase B:同一数据目录「重启」(新模块实例 + 新端口) ---------- console.log('\n== Phase B:重启后(持久化 + 不重复播种) =='); process.env.PORT = '8892'; const B = await startServer(8892, 2); const login2 = await api(B, 'POST', '/auth/login', { username: 'demo', password: 'demo1234' }); ok(login2.status === 200, '重启后 demo 可登录'); const D2 = login2.body.data.token; const rootLogin2 = await api(B, 'POST', '/auth/login', { username: 'root', password: 'password123' }); ok(rootLogin2.status === 200, '重启后 root 可登录', rootLogin2.body); const T2 = rootLogin2.body.data.token; const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D2); ok(fp2.body?.data?.total === 25, '重启后仍是 25 条(测试足迹已被级联删除)', fp2.body?.data?.total); const list2 = (await api(B, 'GET', '/admin/invites', undefined, T2)).body.data.invites; ok(list2.some((i) => i.code === 'XIONG-FRIEND-2025'), '邀请码跨重启持久化'); // ---------- Phase C:先有用户 → 播种绕过邀请码 + 角色规则 ---------- console.log('\n== Phase C:先注册用户 → demo 播种绕过 + 角色规则 =='); const dataC = path.join(tmp, 'C/data'); fs.mkdirSync(dataC, { recursive: true }); process.env.DATA_DIR = dataC; process.env.PORT = '8893'; const C = await startServer(8893, 3); const alice = await api(C, 'POST', '/auth/register', { username: 'alice', password: 'password123', invite_code: 'Ci-Static-Code' }); ok(alice.status === 200, '首位用户用静态码注册(大小写不敏感)', alice.body); const aliceMe = await api(C, 'GET', '/auth/me', undefined, alice.body.data.token); ok(aliceMe.body?.data?.is_admin === true, '首位真实用户(demo 之后注册)自动成为管理员', aliceMe.body); const demoC = await api(C, 'POST', '/auth/login', { username: 'demo', password: 'demo1234' }); ok(demoC.status === 200, '启动播种的 demo 可登录', demoC.body); const demoMe = await api(C, 'GET', '/auth/me', undefined, demoC.body.data.token); ok(demoMe.body?.data?.is_admin === false, 'demo 永远不是管理员', demoMe.body); // seed 绕过:库中已有用户时,register(seed:true) 不要求邀请码(历史 bug 回归) const authC = await import(url.pathToFileURL(path.join(root, 'sync-server/src/auth.mjs')).href + '?phase=3b'); const seeded2 = authC.register({ username: 'seeduser2', password: 'password123', device_name: 't', seed: true }); ok(!!seeded2?.token, '有用户时 register(seed:true) 绕过邀请码成功', seeded2); const noSeed = (() => { try { authC.register({ username: 'noseeduser', password: 'password123', device_name: 't' }); return 'ALLOWED(BAD)'; } catch (e) { return e.message; } })(); ok(noSeed === '需要邀请码,请联系管理员', '同库普通注册仍需邀请码', noSeed); const roles = authC.getDb ? null : null; const dupSeed = (() => { try { authC.register({ username: 'seeduser2', password: 'password123', device_name: 't', seed: true }); return 'DUP'; } catch (e) { return e.message; } })(); ok(dupSeed === '用户名已存在', 'seed 重复播种被拒(幂等)', dupSeed); console.log('\n========== 结果 =========='); console.log('通过 ' + pass + ' / 失败 ' + fail); if (fail) { console.error('失败项:\n - ' + failures.join('\n - ')); process.exit(1); } console.log('✓ 集成测试全部通过'); process.exit(0);