diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..57ae4ff --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,22 @@ +name: ci +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + # 前端依赖(tsc/vite);服务端零依赖不需要安装 + - name: Install frontend deps + run: npm install --prefix app --no-audit --no-fund + + - name: Static checks (syntax + tsc + build + tripwire) + run: node scripts/check.mjs + + - name: Integration tests (seed/login/invite-CRUD/registration) + run: node scripts/test-integration.mjs diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..877c3b8 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# 防两类事故:改动没进版本库(pathspec 漏提交)+ 红了还推 +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +if [ -n "$(git status --porcelain)" ]; then + echo "✗ 工作区有未提交变更,push 已阻止(改动必须先进版本库):" >&2 + git status --porcelain >&2 + exit 1 +fi +node scripts/check.mjs +node scripts/test-integration.mjs +echo "✓ pre-push 检查全部通过" diff --git a/.gitignore b/.gitignore index 9093e88..8224cda 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ parse_docx.py check_yaml.py docx_content.txt *.log + +# 集成测试临时目录 +.tmp-test/ diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..853c762 --- /dev/null +++ b/scripts/check.mjs @@ -0,0 +1,62 @@ +// 静态检查:语法 + 类型 + 构建 + 关键标记 tripwire(CI 与 pre-push 共用) +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +const root = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..'); +let failures = 0; +const step = (label, fn) => { + process.stdout.write('==> ' + label + ' ... '); + try { fn(); console.log('OK'); } + catch (e) { console.log('FAIL\n' + String(e.message || e).slice(-2000)); failures++; } +}; + +// 1) 所有服务端 .mjs 语法检查(新增文件自动纳入) +const mjsDirs = ['sync-server/src', 'app/server', 'scripts']; +const mjsFiles = mjsDirs.flatMap((d) => + fs.readdirSync(path.join(root, d)).filter((f) => f.endsWith('.mjs')).map((f) => path.join(d, f))); +step('node --check × ' + mjsFiles.length + ' 个文件', () => { + for (const f of mjsFiles) { + const r = spawnSync(process.execPath, ['--check', path.join(root, f)], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(f + '\n' + r.stderr); + } +}); + +// 2) TypeScript 类型检查 +const tsc = path.join(root, 'app/node_modules/typescript/bin/tsc'); +if (fs.existsSync(tsc)) { + step('tsc --noEmit(前端类型)', () => { + const r = spawnSync(process.execPath, [tsc, '--noEmit'], { cwd: path.join(root, 'app'), encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stdout || r.stderr); + }); +} else console.log('==> tsc 跳过(app/node_modules 不存在,先安装依赖)'); + +// 3) 前端构建(确保源码可构建;产物不提交) +const vite = path.join(root, 'app/node_modules/vite/bin/vite.js'); +if (fs.existsSync(vite)) { + step('vite build', () => { + const r = spawnSync(process.execPath, [vite, 'build'], { cwd: path.join(root, 'app'), encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stdout || r.stderr); + }); +} else console.log('==> vite 跳过(app/node_modules 不存在)'); + +// 4) 关键文件 tripwire:防止「改了没提交」类事故再次发生 +step('关键标记 tripwire', () => { + const musts = [ + ['app/server/db.mjs', "case 'demo_seed'"], + ['app/server/db.mjs', 'DEMO_ROWS'], + ['app/public/sw.js', 'fp-static-v3'], + ['sync-server/src/db.mjs', "name='remark'"], + ['sync-server/src/index.mjs', 'demo_seed'], + ]; + for (const [f, needle] of musts) { + const p = path.join(root, f); + if (!fs.existsSync(p) || !fs.readFileSync(p, 'utf8').includes(needle)) { + throw new Error(f + ' 缺少关键标记: ' + needle); + } + } +}); + +if (failures) { console.error('\n✗ check 失败:' + failures + ' 项'); process.exit(1); } +console.log('\n✓ check 全部通过'); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..1482139 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,25 @@ +// 部署后冒烟:node scripts/smoke.mjs +const [base, user, pass] = process.argv.slice(2); +if (!base || !user || !pass) { console.error('用法: node scripts/smoke.mjs '); process.exit(2); } +let fail = 0; +const ok = (cond, label, extra) => { console.log((cond ? ' ✓ ' : ' ✗ ') + label + (cond ? '' : ' | ' + JSON.stringify(extra).slice(0, 160))); if (!cond) fail++; }; + +const health = await fetch(base + '/health').then((r) => r.json()).catch((e) => ({ error: String(e) })); +ok(health?.data?.status === 'ok', '/health', health); + +const login = await fetch(base + '/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user, password: pass }) }).then((r) => r.json()).catch((e) => ({ error: String(e) })); +ok(login?.ok === true, '登录', login); +const token = login?.data?.token; +if (!token) { console.error('登录失败,后续跳过'); process.exit(1); } + +const me = await fetch(base + '/auth/me', { headers: { authorization: 'Bearer ' + token } }).then((r) => r.json()); +ok(me?.data?.username === user, '/auth/me 用户名', me); + +const fp = await fetch(base + '/api/footprint_list', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token }, body: JSON.stringify({ query: { page: 1, page_size: 1 } }) }).then((r) => r.json()); +ok(fp?.ok === true, '/api/footprint_list(当前账号 ' + (fp?.data?.total ?? '?') + ' 条)', fp?.error); + +const inv = await fetch(base + '/admin/invites', { headers: { authorization: 'Bearer ' + token } }).then((r) => r.json()).catch(() => null); +if (inv) ok(inv?.ok === true, '/admin/invites(管理员)', inv); else console.log(' - 非管理员,跳过 invites 检查'); + +console.log(fail ? '\n✗ 冒烟未通过' : '\n✓ 冒烟通过'); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-integration.mjs b/scripts/test-integration.mjs new file mode 100644 index 0000000..6b2b87c --- /dev/null +++ b/scripts/test-integration.mjs @@ -0,0 +1,131 @@ +// 集成测试:把历史 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 T = demoLogin.body.data.token; + +const me = await api(A, 'GET', '/auth/me', undefined, T); +ok(me.body?.data?.username === 'demo' && me.body?.data?.is_admin === true, '/auth/me 返回 demo 且是管理员', me.body); + +const fp = await api(A, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, T); +ok(fp.body?.data?.total === 16, '演示足迹 16 条(播种成功)', fp.body?.data?.total); +const grades = await api(A, 'POST', '/api/grades_overview', {}, T); +ok(grades.body?.data?.semesters?.length === 4, '演示学期 4 个', grades.body?.data?.semesters?.length); +const tl = await api(A, 'POST', '/api/timeline', { query: {} }, T); +ok((tl.body?.data?.groups?.length ?? 0) >= 1, 'timeline 有分组'); +const st = await api(A, 'POST', '/api/stats_overview', {}, T); +ok(st.body?.ok === true, 'stats_overview 可用'); + +// 邀请码:增 +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); + +// ---------- 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 T2 = login2.body.data.token; +const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, T2); +ok(fp2.body?.data?.total === 16, '重启后仍是 16 条(无重复播种)', 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 === false, 'alice 在启动播种的 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 === true, '启动播种的 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);