Files
ny-exam-platform/tools/test-config-save.js
T
nxxy335 157f257728 feat: 成绩明细打印 + SQLite + 每卷独立配置(满分/合格率/免考) + 导航精简
- 成绩明细打印:默认成绩降序,可选按班级/组别分组对比(段首小计)
- 修复看板退出全屏无反应(exitFullscreen 挂在 document 而非 documentElement)
- 导航栏精简:三页统一为单一主标题,移除 Logo 与副标题
- 优化三端布局:重排断点、竖排隐藏 spacer、触摸设备热区
- 全面启用 SQLite(data/ny.db):配置/名单/每卷配置入库,无 pdo_sqlite 自动降级文件
- 新增独立合格率配置(全局 + 每卷),合格率不再写死 60%
- 新增免考配置,仅对当前试卷生效,试卷间隔离
- 试卷独立满分从设置移出,改到主页面「试卷设置」
- 测试:新增 4 条每卷配置隔离断言,全套 113 项通过
2026-09-10 20:22:17 +08:00

338 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// e2e: config_save 仅连接信息变化才清 token;各试卷独立满分修复;密码不回显
// 运行:node tools/test-config-save.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18081;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
// 1) 备份 / 准备文件存储模式
const cfgFile = path.join(ROOT, 'data', 'config.json');
const cfgBak = cfgFile + '.bak';
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json');
const tokenBak = tokenFile + '.bak';
const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
if (hasToken) fs.renameSync(tokenFile, tokenBak);
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。
const dbFile = path.join(ROOT, 'data', 'ny.db');
const dbBak = dbFile + '.bak';
const hasDb = fs.existsSync(dbFile);
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
const throttleFile = path.join(ROOT, 'data', 'login_fail.json');
if (fs.existsSync(throttleFile)) { try { fs.unlinkSync(throttleFile); } catch (e) {} }
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: 'mock',
password: 'surveyking',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
include_temp_save: false,
dedupe: true,
name_keywords: '姓名,名字',
full_score: 100,
full_scores: {},
layout: 'default',
}, null, 2));
// 统一收尾钩子:恢复原 config + token + kill 子进程。任何退出路径都会触发
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch(e){}
try { mock && mock.kill('SIGKILL'); } catch(e){}
try {
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
} catch(e) { console.error('cleanup cfg failed:', e.message); }
try {
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
} catch(e) { console.error('cleanup token failed:', e.message); }
// 恢复 SQLite(本轮测试新建的 ny.db 丢弃,换回原有的)
try {
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
} catch(e) { console.error('cleanup db failed:', e.message); }
try {
if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile);
} catch(e) {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
// 2) 启动 mock 卷王
let mock = null;
let srv = null;
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
function req(method, path, body, headers) {
return new Promise((resolve, reject) => {
// 写操作自动附带 CSRF token(后端对所有写 action 强制校验)
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
const data = body ? JSON.stringify(body) : '';
const h = Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
}, headers || {});
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method, path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let j = null;
try { j = JSON.parse(text); } catch (e) {}
// 收集 Set-Cookie
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
// 跟随 302(不自动跟随 action=login 等会导致副作用的)
if (res.statusCode === 302 && res.headers.location && !h['__followed'] && !/login\.php/.test(res.headers.location)) {
h['__followed'] = 1;
console.log(' [debug] follow', res.statusCode, '→', res.headers.location);
return resolve(req('GET', res.headers.location, null, h));
}
resolve({ status: res.statusCode, body: j, raw: text, setCookie: res.headers['set-cookie'], loc: res.headers.location });
});
});
r.on('error', reject);
if (data) r.write(data);
r.end();
});
}
const cookieJar = [];
let CSRF = ''; // 从 config_get 取到的会话 CSRF token,POST 写请求自动附带
function formPost(path, fields) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(fields).toString();
const h = {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
};
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path, headers: h }, res => {
let chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
if (res.headers['set-cookie']) {
for (const sc of res.headers['set-cookie']) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
}
}
resolve({ status: res.statusCode, body: text, loc: res.headers.location });
});
});
r.on('error', reject);
r.write(body); r.end();
});
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
async function login() {
// 走 login.php 的表单登录拿会话
return await formPost('/login.php', { username: 'mock', password: 'surveyking' });
}
(async () => {
// 等服务起来
await delay(2000);
// 用 inline 脚本快速验证 login 是否能成功(保留 srv 不退出)
await new Promise(r => setTimeout(r, 200));
const http = require('http');
const testCookie = await new Promise((resolve) => {
const body = new URLSearchParams({username:'mock',password:'surveyking'}).toString();
http.request({host:'127.0.0.1',port:PORT,method:'POST',path:'/login.php',headers:{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}},res=>{
let c=[]; res.on('data',d=>c.push(d)); res.on('end',()=>{
const text = Buffer.concat(c).toString();
const cookie = (res.headers['set-cookie']||[]).map(c=>c.split(';')[0]).join('; ');
console.log(' [inline] login status', res.statusCode, 'set-cookie', JSON.stringify(res.headers['set-cookie']));
if (text.indexOf('登录失败') >= 0) {
const m = text.match(/登录失败[^<]+/);
console.log(' [inline] body 错误:', m ? m[0] : '(unknown)');
} else {
console.log(' [inline] body end:', text.slice(Math.max(0,text.length-200)));
}
// 拿同一 cookie 调 auth_check
http.request({host:'127.0.0.1',port:PORT,method:'GET',path:'/api.php?action=auth_check',headers:{Cookie:cookie}}, rs=>{
let cc=[]; rs.on('data',d=>cc.push(d)); rs.on('end',()=>{
console.log(' [inline] auth_check', rs.statusCode, Buffer.concat(cc).toString());
resolve(cookie);
});
}).end();
});
}).end(body);
});
if (testCookie && testCookie.indexOf('NYSESS=') >= 0) {
// 同步 jar
cookieJar.length = 0;
testCookie.split('; ').forEach(c => cookieJar.push(c));
}
console.log('\n--- 1) 首次登录应成功 ---');
// 登录 formPost 不会跟随 302(直接保留 body),但要求 NYSESS cookie 出现
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
console.log(' [debug] login loc:', lg.loc, 'body:', (lg.body||'').slice(0, 200));
chk('login.php 200/302', lg.status === 302 || lg.status === 200, 'status=' + lg.status);
chk('session cookie 已获得', cookieJar.length > 0, 'jar=' + JSON.stringify(cookieJar));
// 复查 auth(直接走 Session ID 路径)
const ac = await req('GET', '/api.php?action=auth_check&_t=' + Date.now());
console.log(' [debug] auth_check:', ac.status, ac.raw);
chk('auth_check 通过', ac.body && ac.body.data && ac.body.data.auth === true, JSON.stringify(ac.body));
if (!(ac.body && ac.body.data && ac.body.data.auth)) {
console.log(' [fatal] 未登录,停止测试');
mock.kill('SIGTERM'); srv.kill('SIGTERM');
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
process.exit(1);
}
// 之后用 ping 触发 SurveyKing 登录
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now()); console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
await delay(100);
chk('token 已缓存', fs.existsSync(tokenFile));
console.log('\n--- 2) config_get 应返回 storage + 诊断 ---');
const cg1 = await req('GET', '/api.php?action=config_get');
chk('config_get 200', cg1.status === 200);
chk('返回 storage 字段', cg1.body && cg1.body.data && typeof cg1.body.data.storage === 'string',
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
// 取 CSRF token,后续写请求自动附带
CSRF = (cg1.body && cg1.body.data && cg1.body.data.csrf_token) || '';
chk('config_get 返回 csrf_token', CSRF !== '', 'csrf=' + CSRF);
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
const cs1 = await req('POST', '/api.php?action=config_save', {
full_score: 95,
});
chk('config_save 200', cs1.status === 200);
chk('conn_changed=false', cs1.body && cs1.body.data && cs1.body.data.conn_changed === false);
chk('ping 字段不存在', cs1.body && cs1.body.data && cs1.body.data.ping === undefined);
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('token 保持未变', before === after, 'changed=' + (before !== after));
console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
// 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
full_score: 150, pass_rate: 70, exempt: 'S1,S2',
});
chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
&& pc1.body.data.effective.full_score === 150,
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
&& Number(pc1.body.data.effective.pass_rate) === 70,
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
// 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
// 所以这里不能写死 100,必须与响应里的 global 比对)
const pc2 = await req('GET', '/api.php?action=project_config&projectId=p2');
const gFs = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.full_score : null;
const gPr = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.pass_rate : null;
chk('p2 隔离:满分跟随全局(=' + gFs + ')', pc2.body && pc2.body.data && pc2.body.data.effective
&& pc2.body.data.effective.full_score === gFs,
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gFs);
chk('p2 隔离:合格率跟随全局(=' + gPr + ')', pc2.body && pc2.body.data && pc2.body.data.effective
&& Number(pc2.body.data.effective.pass_rate) === Number(gPr),
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gPr);
// 免考名单往返
const pc3 = await req('GET', '/api.php?action=project_config&projectId=p1');
const ex = pc3.body && pc3.body.data && pc3.body.data.cfg && pc3.body.data.cfg.exempt;
chk('免考名单往返 = [S1,S2]', Array.isArray(ex) && ex.length === 2 && ex[0] === 'S1' && ex[1] === 'S2',
JSON.stringify(ex));
// 清除本卷设置 → 回到全局
const pc4 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
full_score: '', pass_rate: '', exempt: '',
});
chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
&& pc4.body.data.effective.full_score === gFs,
JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
// 最先修改密码为不同值:触发 conn_changed=true + ping.ok=false
const cs5 = await req('POST', '/api.php?action=config_save', {
password: 'temp-wrong-password',
});
chk('修改密码后 conn_changed=true', cs5.body && cs5.body.data && cs5.body.data.conn_changed === true);
chk('返回 ping.ok=false', cs5.body && cs5.body.data && cs5.body.data.ping && cs5.body.data.ping.ok === false,
JSON.stringify(cs5.body && cs5.body.data && cs5.body.data.ping));
await delay(200);
const tokAfter = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
chk('修改同 token 被清', tokBefore !== '' && tokAfter === '', 'before=' + (tokBefore !== '') + ' after=' + (tokAfter !== ''));
// 第二步:改回正确密码,应 trigger conn_changed=true + ping.ok=true
const cs5b = await req('POST', '/api.php?action=config_save', {
password: 'surveyking',
});
chk('改回正确密码同 conn_changed=true', cs5b.body && cs5b.body.data && cs5b.body.data.conn_changed === true);
chk('改回后 ping.ok=true', cs5b.body && cs5b.body.data && cs5b.body.data.ping && cs5b.body.data.ping.ok === true,
JSON.stringify(cs5b.body && cs5b.body.data && cs5b.body.data.ping));
await delay(200);
chk('登录成功同 token 已生成', fs.existsSync(tokenFile), 'token exists=' + fs.existsSync(tokenFile));
console.log('\n--- 6) config_get 永远不带明文密码 ---');
const cg2 = await req('GET', '/api.php?action=config_get');
chk('密码不存在', cg2.body && cg2.body.data && cg2.body.data.password === undefined);
chk('has_password 标志', cg2.body && cg2.body.data && cg2.body.data.has_password === true);
console.log('\n--- 7) 修改为错误密码:应自动 ping 失败并保留弹窗提示 ---');
const cs6 = await req('POST', '/api.php?action=config_save', {
username: 'mock',
password: 'wrong-password',
});
chk('conn_changed=true', cs6.body && cs6.body.data && cs6.body.data.conn_changed === true);
chk('ping.ok=false', cs6.body && cs6.body.data && cs6.body.data.ping && cs6.body.data.ping.ok === false,
JSON.stringify(cs6.body && cs6.body.data && cs6.body.data.ping));
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
// 清理 + 退出(统一走 cleanup 钩子,保证 config/token 一定被还原)
cleanup();
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
})().catch(e => { console.error('FATAL', e); cleanup(); process.exit(2); });