Files
ny-exam-platform/tools/test-config-save.js
T
nxxy335 3952c77afe refactor: 取消 install.php 安装向导 + 下线 pgsql/mysql 支持 + 班级看板独立
- 删除 install.php:所有入口(index/dashboard/draw/login/api)不再跳安装,
  未登录统一跳 login.php;连接信息变更在【设置】里直接修改 data/config.json
- 下线 pgsql/mysql:
  * bootstrap.php 简化为单文件存储路径(ny_load_config/ny_save_config 直接读写 data/config.json),
    删除 ny_db_config、配置迁移、配置双写分支
  * lib/Db.php 改为纯 stub:pdo() 恒返回 null,避免上层误连;migrate/ensureRosterLogin 等为 no-op
  * Roster.php 经 useDb 永远 false,自动走 roster.json
  * api.php 不再返回 db_driver/db_connected/db_error 诊断
- 班级看板独立(dashboard.php 重写):
  * 删除"← 返回总览"按钮 + 整体概况 #overview 区
  * 进入即展示单班看板:6 张汇总卡 + 分组对比/成员榜
  * 顶部 hero 区动态显示当前班级名 + 问卷名
  * 工具栏新增 #classSel 班级选择器(切换班级不离开本页)
  * URL 支持 ?pid=&class=&auto=1|0 分享与刷新恢复
  * 下拉班级选项随数据动态刷新;保留自动刷新 + 全屏 + 缓存
- e2e 测试(test-config-save)加固 cleanup 钩子:
  * 统一 cleanup() 函数接管所有退出路径
  * 解决之前 e2e 留 residue 把 data/config.json 改成 mock 地址的问题(导致本机登录失败)
- php -l 全文件通过;node --check 内嵌 JS 通过;e2e test-config-save 27/27 + test-draw-alloc 29/29 全过
2026-09-09 14:04:59 +08:00

305 lines
15 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);
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); }
}
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) => {
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 = [];
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);
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) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
const cs2 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
});
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
? cs2.body.data.config.full_scores : undefined;
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
_cs2fs === undefined || Object.keys(_cs2fs).length === 0,
JSON.stringify(_cs2fs));
// 显式 apply_pid='p1', apply_score=85 → 写入
const cs3 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 85,
});
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
// apply_pid='p1', apply_score=0 → 清除
const cs4 = await req('POST', '/api.php?action=config_save', {
full_score: 90,
apply_pid: 'p1',
apply_score: 0,
});
chk('apply_pid=0 → 清除 p1',
cs4.body && cs4.body.data && cs4.body.data.config
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
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); });