Files
ny-exam-platform/tools/test-whitelist.js
T
nxxy335 eb5d06d392 修复考试白名单驱动缺/待考名单:改用卷王项目参与者(Project Partners)真实模型
旧代码误读 surveySetting.whiteLists(实为题目选项白名单),导致线上从未触发。现改为读取 /api/project/partner/list 的 RESPONDENT_SYS_USER/IMP_USER 参与者作为应考名单,并保留 whiteLists 作为极老版本兜底。

dashboard.php 移除未实现的提交率卡片;全_score 默认 100 已在 data 层修正。e2e 测试 mock 与断言同步改为真实模型,全量 132 测试通过。
2026-09-13 11:02:34 +08:00

214 lines
10 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: 卷内「考试白名单 / 通讯录名单」自动驱动缺/待考名单(DataService::build)
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
//
// 真实卷王模型:应考名单 = 项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
// 经 /api/project/partner/list 返回;不再是旧代码误读的 surveySetting.whiteLists(题目选项白名单)。
// 覆盖:
// 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员)
// 2) p2 未配置受访者 → 按现状(全名单)显示,名单外成员仍在应考/缺考中
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
const http = require('http');
const net = require('net');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PHP_PORT = 18082;
const MOCK_PORT = 19911;
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';
const DEBUG = !!process.env.NY_DEBUG;
function resolveDataDir() {
let p = process.env.NY_DATA || path.join(ROOT, 'data');
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + p.slice(2);
p = path.resolve(p);
process.env.NY_DATA = p;
return p;
}
const DATA = resolveDataDir();
fs.mkdirSync(DATA, { recursive: true });
const UPLOADS = path.join(DATA, 'uploads');
const moved = [], copied = [];
for (const f of ['ny.db', 'cache/token.json']) {
const p = path.join(DATA, f);
if (fs.existsSync(p)) {
const bak = p + '.nybak';
try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
}
}
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
const p = path.join(DATA, f);
if (fs.existsSync(p)) {
const bak = p + '.nybak';
try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
}
}
const cfgFile = path.join(DATA, 'config.json');
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:' + MOCK_PORT,
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,
pass_rate: 60,
layout: 'default',
}, null, 2));
let mock = null, srv = null, cleanedUp = false;
function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
killAll();
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
const p = path.join(DATA, f);
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
}
for (const [p, bak] of moved.concat(copied)) {
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } 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); });
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
function waitPort(port, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
(function attempt() {
const s = net.connect({ host: '127.0.0.1', port });
const done = (ok) => { s.destroy(); resolve(ok); };
s.setTimeout(800, () => done(false));
s.on('connect', () => done(true));
s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
})();
});
}
const cookieJar = [];
let CSRF = '';
function baseHeaders() {
const h = {};
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
return h;
}
function absorb(res) {
for (const sc of res.headers['set-cookie'] || []) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
function request(method, urlPath, headers, bodyBuf) {
return new Promise((resolve, reject) => {
const h = Object.assign({}, headers || {});
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
});
r.on('error', reject);
if (bodyBuf) r.write(bodyBuf);
r.end();
});
}
const get = (p) => request('GET', p, baseHeaders());
function formPost(p, fields) {
const body = Buffer.from(new URLSearchParams(fields).toString());
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
}
function upload(urlPath, fields, file) {
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
const parts = [];
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
}
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 : '')); }
}
(async () => {
const mockUp = await waitPort(MOCK_PORT, 15000);
const phpUp = await waitPort(PHP_PORT, 15000);
chk('mock 卷王服务已就绪', mockUp);
chk('PHP 内置服务器已就绪', phpUp);
if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
for (let i = 0; i < 3 && CSRF === ''; i++) {
await formPost('/login.php', { username: 'mock', password: 'surveyking' });
const ac = await get('/api.php?action=auth_check');
if (ac.body && ac.body.data && ac.body.data.auth === true) {
const cg = await get('/api.php?action=config_get');
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
}
}
chk('已登录并取到 CSRF token', CSRF !== '');
if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
});
chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
const d1 = await get('/api.php?action=data&projectId=p1');
chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
const d1d = (d1.body && d1.body.data) || {};
chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
const d2 = await get('/api.php?action=data&projectId=p2');
chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
const d2d = (d2.body && d2.body.data) || {};
chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
console.log('');
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
cleanup();
process.exit(fail === 0 ? 0 : 1);
})();