423 lines
36 KiB
JavaScript
423 lines
36 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { readFile, rm } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import assert from 'node:assert/strict';
|
|
import ExcelJS from 'exceljs';
|
|
import { relationalTables } from '../database.mjs';
|
|
import { buildWorkbook } from '../excel.mjs';
|
|
|
|
const root = resolve(process.cwd());
|
|
const port = 4182;
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const testDb = resolve(root, 'data', 'test-db.sqlite');
|
|
await rm(testDb, { force: true });
|
|
await rm(`${testDb}-shm`, { force: true });
|
|
await rm(`${testDb}-wal`, { force: true });
|
|
|
|
const server = spawn(process.execPath, ['server.mjs'], {
|
|
cwd: root,
|
|
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb },
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
|
|
let serverError = '';
|
|
server.stderr.on('data', chunk => { serverError += chunk.toString(); });
|
|
|
|
async function waitForServer() {
|
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
try {
|
|
const response = await fetch(`${baseUrl}/api/public/home`);
|
|
if (response.ok) return;
|
|
} catch {}
|
|
await new Promise(resolveWait => setTimeout(resolveWait, 100));
|
|
}
|
|
throw new Error(`服务器未能启动:${serverError}`);
|
|
}
|
|
|
|
function createClient() {
|
|
let cookie = '';
|
|
return {
|
|
async request(path, options = {}) {
|
|
const binaryBody = Buffer.isBuffer(options.body) || options.body instanceof ArrayBuffer || ArrayBuffer.isView(options.body);
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
...options,
|
|
headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
|
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
|
|
});
|
|
const setCookie = response.headers.get('set-cookie');
|
|
if (setCookie) cookie = setCookie.split(';')[0];
|
|
const type = response.headers.get('content-type') || '';
|
|
const data = type.includes('application/json') ? await response.json() : type.includes('spreadsheetml') ? Buffer.from(await response.arrayBuffer()) : await response.text();
|
|
return { response, data };
|
|
}
|
|
};
|
|
}
|
|
|
|
const admin = createClient();
|
|
const schoolAdmin = createClient();
|
|
const schoolAdmin2 = createClient();
|
|
const classAdmin = createClient();
|
|
const candidate = createClient();
|
|
const selfCandidate = createClient();
|
|
const batchCandidate = createClient();
|
|
const anonymous = createClient();
|
|
|
|
try {
|
|
await waitForServer();
|
|
|
|
const sqliteFile = await readFile(testDb);
|
|
assert.equal(sqliteFile.subarray(0, 16).toString(), 'SQLite format 3\0', '测试持久化文件必须是真实 SQLite 数据库');
|
|
const appSource = await readFile(resolve(root, 'app.js'), 'utf8');
|
|
assert.doesNotMatch(appSource, /onclick=["']event\.stopPropagation\(\)/, '弹窗不得截断内部按钮的委托点击事件');
|
|
assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭');
|
|
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
|
|
|
const { DatabaseSync } = await import('node:sqlite');
|
|
const inspector = new DatabaseSync(testDb, { readOnly: true });
|
|
const tableNames = inspector.prepare(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
ORDER BY name
|
|
`).all().map(row => row.name);
|
|
inspector.close();
|
|
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
|
|
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
|
|
|
const publicHome = await anonymous.request('/api/public/home');
|
|
assert.equal(publicHome.response.status, 200);
|
|
assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知');
|
|
assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目');
|
|
assert.equal(publicHome.data.selfRegistrationEnabled, false, '自主注册默认应关闭');
|
|
|
|
const closedRegister = await candidate.request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: { password: 'Test12345!', name: '测试考生', gender: '男', schoolId: 'school_hz1', classId: 'class_hz1_302' }
|
|
});
|
|
assert.equal(closedRegister.response.status, 403, '自主注册关闭时公开注册必须拒绝');
|
|
|
|
const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } });
|
|
assert.equal(loginAdmin.data.user.role, 'admin');
|
|
assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员');
|
|
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: 'School123!' } })).data.user.adminLevel, 'school');
|
|
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school');
|
|
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class');
|
|
|
|
assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号');
|
|
const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [
|
|
{ classId: 'class_hz1_302', count: 1 }
|
|
] } });
|
|
assert.equal(singleAccountBatch.response.status, 202, '即使只申领一个账号,校级管理员也应提交审批');
|
|
assert.ok(singleAccountBatch.data.batch.items.every(item => !item.candidateNumber), '最终批准前不得生成账号');
|
|
const approvedSingleBatch = await admin.request(`/api/admin/candidate-account-batches/${singleAccountBatch.data.batch.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '单人名额核验通过' } });
|
|
assert.equal(approvedSingleBatch.response.status, 200);
|
|
const candidateNumber = approvedSingleBatch.data.batch.items[0].candidateNumber;
|
|
const initialPassword = approvedSingleBatch.data.batch.items[0].initialPassword;
|
|
assert.match(candidateNumber, /^2026-HZ01-X-\d{4}$/, '占位账户应按当前规则生成固定报名号');
|
|
const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: candidateNumber, password: initialPassword } });
|
|
assert.equal(loginCandidate.data.user.mustChangePassword, true, '学校下发账户首次登录必须修改初始密码');
|
|
assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未修改初始密码前不得进入考生业务');
|
|
const changedPassword = await candidate.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: initialPassword, newPassword: 'Test12345!' } });
|
|
assert.equal(changedPassword.data.user.mustChangePassword, false, '修改密码后应解除首次登录限制');
|
|
assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务');
|
|
const updateProfile = await candidate.request('/api/candidate/profile', {
|
|
method: 'PUT',
|
|
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市测试区 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888' }
|
|
});
|
|
assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料');
|
|
assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成');
|
|
assert.equal(updateProfile.data.profile.status, 'pending', '完整资料应进入审核');
|
|
const refreshedSession = await candidate.request('/api/auth/me');
|
|
assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步');
|
|
const candidateCannotAdmin = await candidate.request('/api/admin/dashboard');
|
|
assert.equal(candidateCannotAdmin.response.status, 403, '考生不得访问管理接口');
|
|
|
|
assert.equal((await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: true } })).response.status, 200, '超级管理员应能开启自主注册');
|
|
const openHome = await anonymous.request('/api/public/home');
|
|
assert.equal(openHome.data.selfRegistrationEnabled, true, '公开端应同步自主注册开关');
|
|
const selfRegister = await selfCandidate.request('/api/auth/register', { method: 'POST', body: { password: 'Self12345!', name: '自主注册考生', gender: '女', schoolId: 'school_hz3', classId: 'class_hz3_301' } });
|
|
assert.equal(selfRegister.response.status, 201, '开关开启后考生应可自主申请固定报名号');
|
|
assert.match(selfRegister.data.registrationNumber, /^2026-HZ03-F-\d{4}$/);
|
|
await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: false } });
|
|
|
|
const adminDirectory = await admin.request('/api/admin/admins');
|
|
assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员');
|
|
const schoolCenters = await schoolAdmin.request('/api/admin/centers');
|
|
assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点');
|
|
const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: {
|
|
code: 'HZ01-EAST', name: '海州市第一中学东区考点', address: '海州市测试路 8 号', contact: '0518-12345678',
|
|
managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案',
|
|
rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatPlan: '按现场座次表编排', roomType: 'standard', status: 'active', notes: '' }]
|
|
} });
|
|
assert.equal(newCenter.response.status, 202, '新增考点应创建审批申请而不是直接落库');
|
|
assert.equal(newCenter.data.changeRequest.schoolId, 'school_hz1', '校级管理员新增考点必须自动归属本校');
|
|
const beforeCenterApproval = await schoolAdmin.request('/api/admin/centers');
|
|
assert.ok(!beforeCenterApproval.data.centers.some(item => item.code === 'HZ01-EAST'), '考点审批通过前不得进入正式档案');
|
|
const approveCenter = await admin.request(`/api/admin/center-change-requests/${newCenter.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考务条件核验通过' } });
|
|
assert.equal(approveCenter.response.status, 200, '超级管理员应可审批考点变更');
|
|
const afterCenterApproval = await schoolAdmin.request('/api/admin/centers');
|
|
const createdCenter = afterCenterApproval.data.centers.find(item => item.code === 'HZ01-EAST');
|
|
assert.equal(createdCenter.rooms.length, 1, '审批通过后应同时写入结构化考场');
|
|
assert.equal(createdCenter.totalCapacity, 30, '考场容量应汇总到考点档案');
|
|
const centerUpdate = await schoolAdmin.request(`/api/admin/centers/${createdCenter.id}`, { method: 'PATCH', body: {
|
|
...createdCenter, managerName: '变更后负责人', rooms: [
|
|
...createdCenter.rooms,
|
|
{ code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatPlan: '无障碍座位现场标记', roomType: 'accessible', status: 'active', notes: '无障碍通道' }
|
|
]
|
|
} });
|
|
assert.equal(centerUpdate.response.status, 202, '修改考点和考场也必须提交审批');
|
|
const unchangedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id);
|
|
assert.equal(unchangedCenter.managerName, '测试负责人', '变更审批通过前正式档案不得改变');
|
|
await admin.request(`/api/admin/center-change-requests/${centerUpdate.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '同意扩充考场' } });
|
|
const changedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id);
|
|
assert.equal(changedCenter.managerName, '变更后负责人', '审批通过后正式考点档案应更新');
|
|
assert.equal(changedCenter.rooms.length, 2, '审批通过后考场明细应按申请快照整体替换');
|
|
assert.equal((await classAdmin.request('/api/admin/centers')).response.status, 403, '班级管理员不得读取或维护考点');
|
|
|
|
const organization = await schoolAdmin.request('/api/admin/school-organization');
|
|
assert.equal(organization.response.status, 200, '校级管理员应有本校组织管理入口');
|
|
assert.ok(organization.data.classes.every(item => item.schoolId === 'school_hz1'), '组织页只能返回本校班级');
|
|
const createClass = await schoolAdmin.request('/api/admin/classes', { method: 'POST', body: { grade: '高二', name: '高二(9)班', active: true } });
|
|
assert.equal(createClass.response.status, 201, '校级管理员应能新增本校班级');
|
|
const newClassId = createClass.data.schoolClass.id;
|
|
const createClassAdmin = await schoolAdmin.request('/api/admin/admins', { method: 'POST', body: { displayName: '批量测试班管', username: 'class_excel_test', password: 'ClassExcel123!', classId: newClassId } });
|
|
assert.equal(createClassAdmin.response.status, 201, '校级管理员应能创建并绑定本校班级管理员');
|
|
assert.equal(createClassAdmin.data.admin.adminLevel, 'class');
|
|
assert.equal(createClassAdmin.data.admin.schoolId, 'school_hz1');
|
|
assert.equal((await schoolAdmin.request(`/api/admin/admins/${createClassAdmin.data.admin.id}`, { method: 'PATCH', body: { classId: 'class_hz1_301', active: false } })).response.status, 200, '校级管理员应能调整或停用本校班级管理员');
|
|
assert.equal((await admin.request('/api/admin/classes', { method: 'POST', body: { grade: '越权', name: '越权班' } })).response.status, 403, '超级管理员不应代替学校维护班级');
|
|
|
|
const classTemplate = await schoolAdmin.request('/api/admin/excel/classes?template=1');
|
|
assert.equal(classTemplate.response.status, 200, '班级 Excel 模板应可下载');
|
|
assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/);
|
|
const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data);
|
|
assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头');
|
|
const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }]));
|
|
const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile });
|
|
assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级');
|
|
assert.equal(classImport.data.count, 1);
|
|
const quotaFile = Buffer.from(await buildWorkbook('account_quotas', [{ className: '高三(1)班', count: 12 }, { className: '高三(2)班', count: 8 }]));
|
|
const quotaImport = await schoolAdmin.request('/api/admin/excel/account_quotas', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: quotaFile });
|
|
assert.deepEqual(quotaImport.data.quotas.map(item => item.count), [12, 8], '报名号配额 Excel 应解析并回填各班数量');
|
|
const centerTemplate = await schoolAdmin.request('/api/admin/excel/centers?template=1');
|
|
const centerWorkbook = new ExcelJS.Workbook(); await centerWorkbook.xlsx.load(centerTemplate.data);
|
|
const centerHeaders = centerWorkbook.worksheets[0].getRow(2).values.map(String);
|
|
assert.ok(centerHeaders.includes('座位编排说明'), '考场 Excel 应使用座位编排说明');
|
|
assert.ok(!centerHeaders.some(item => item.includes('座位起号') || item.includes('座位止号')), '考场 Excel 不应再出现座位起止号');
|
|
assert.equal(centerWorkbook.worksheets[0].getCell('M3').value, '001', '考场代码应保留前导零');
|
|
assert.equal(centerWorkbook.worksheets[0].getCell('M3').numFmt, '@', '标识号列应固定为文本格式');
|
|
const classAdminImportFile = Buffer.from(await buildWorkbook('class_admins', [{ schoolCode: 'HZ01', className: '高一(8)班', displayName: 'Excel 班管', username: 'excel_class_admin', initialPassword: 'ExcelClass123!', status: '启用' }]));
|
|
const classAdminImport = await schoolAdmin.request('/api/admin/excel/class_admins', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classAdminImportFile });
|
|
assert.equal(classAdminImport.data.count, 1, '班级管理员应支持 Excel 批量导入');
|
|
const centerImportFile = Buffer.from(await buildWorkbook('centers', [{
|
|
schoolCode: 'HZ01', centerCode: 'HZ01-XLSX', centerName: 'Excel 导入考点', address: '海州市表格路 1 号', managerName: '表格负责人', managerPhone: '13800138001', contact: '0518-88000000', emergencyPhone: '0518-120', gateOpenTime: '07:10', transport: '北门入场', centerStatus: '启用', centerNotes: 'Excel 自动化测试',
|
|
roomCode: 'X001', roomName: 'Excel 第 001 考场', building: '综合楼', floor: '2 层', capacity: 32, seatPlan: '按现场桌贴编排', roomType: '标准考场', roomStatus: '启用', roomNotes: ''
|
|
}]));
|
|
const centerImport = await schoolAdmin.request('/api/admin/excel/centers', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: centerImportFile });
|
|
assert.equal(centerImport.response.status, 200, '考点考场 Excel 导入应创建审批申请');
|
|
assert.equal(centerImport.data.count, 1);
|
|
const excelPendingCenter = (await schoolAdmin.request('/api/admin/centers')).data.changeRequests.find(item => item.code === 'HZ01-XLSX');
|
|
assert.ok(excelPendingCenter && excelPendingCenter.status === 'pending', 'Excel 导入的考点必须进入审批而非直接生效');
|
|
assert.equal(excelPendingCenter.rooms[0].seatPlan, '按现场桌贴编排');
|
|
assert.equal((await schoolAdmin.request('/api/admin/excel/candidates')).response.status, 200, '考生资料应支持按学校范围导出 Excel');
|
|
assert.equal((await schoolAdmin.request('/api/admin/excel/results')).response.status, 200, '成绩台账应支持按学校范围导出 Excel');
|
|
assert.equal((await classAdmin.request('/api/admin/excel/centers')).response.status, 403, '班级管理员不得通过 Excel 接口越权导出考场');
|
|
|
|
const numberRules = await admin.request('/api/admin/number-rules');
|
|
assert.ok(numberRules.data.activeRule.segments.some(item => item.type === 'sequence'), '系统应提供启用的报名号规则');
|
|
const saveNumberRule = await admin.request('/api/admin/number-rules', { method: 'POST', body: {
|
|
id: numberRules.data.activeRule.id, name: '测试组合规则', separator: '-', segments: [
|
|
{ type: 'year', width: 4 }, { type: 'school_code' }, { type: 'gender' }, { type: 'sequence', width: 4 }
|
|
]
|
|
} });
|
|
assert.equal(saveNumberRule.response.status, 200, '超级管理员应可自由组合并启用报名号逻辑');
|
|
assert.equal(numberRules.data.batchCandidates, undefined, '超级管理员号码规则页不应再提供直接批量建号数据');
|
|
assert.equal((await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: {} })).response.status, 404, '旧的超级管理员直接批量生成接口应移除');
|
|
|
|
const candidatesBeforeBatch = (await schoolAdmin.request('/api/admin/candidates')).data.candidates.length;
|
|
const createAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [
|
|
{ classId: 'class_hz1_301', count: 2 }, { classId: 'class_hz1_302', count: 1 }
|
|
] } });
|
|
assert.equal(createAccountBatch.response.status, 202, '校级管理员应可按多个班级数量提交批量建号申请');
|
|
assert.equal(createAccountBatch.data.batch.totalCount, 3, '批次总数应等于各班级配额之和');
|
|
assert.deepEqual(createAccountBatch.data.batch.quotas.map(item => item.count).sort(), [1, 2], '批次应保留每个班级的独立数量');
|
|
assert.ok(createAccountBatch.data.batch.items.every(item => !item.candidateNumber && !item.initialPassword), '审批通过前不得提前生成报名号或初始密码');
|
|
assert.equal((await schoolAdmin.request('/api/admin/candidates')).data.candidates.length, candidatesBeforeBatch, '审批通过前不得创建任何考生账户');
|
|
const batchId = createAccountBatch.data.batch.id;
|
|
assert.equal((await schoolAdmin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved' } })).response.status, 403, '校级申请人不得绕过超级管理员审批');
|
|
const batchFlowList = await admin.request('/api/admin/workflow-instances');
|
|
const batchFlow = batchFlowList.data.instances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batchId);
|
|
assert.equal(batchFlow.batchTotalCount, 3, '超级管理员流程中心应展示批次总数');
|
|
assert.deepEqual(batchFlow.accountBatch.quotas.map(item => item.count).sort(), [1, 2], '流程详情应展示班级配额');
|
|
const approveAccountBatch = await admin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学籍名额核验通过' } });
|
|
assert.equal(approveAccountBatch.response.status, 200, '超级管理员最终批准后应生成整批账号');
|
|
assert.equal(approveAccountBatch.data.batch.status, 'approved');
|
|
assert.equal(approveAccountBatch.data.batch.items.length, 3);
|
|
assert.ok(approveAccountBatch.data.batch.items.every(item => /^2026-HZ01-X-\d{4}$/.test(item.candidateNumber)), '批量占位账户应按规则使用未知性别 X 生成固定号码');
|
|
assert.equal(new Set(approveAccountBatch.data.batch.items.map(item => item.candidateNumber)).size, 3, '批量生成的报名号必须唯一');
|
|
assert.ok(approveAccountBatch.data.batch.items.every(item => item.initialPassword.startsWith('Init-')), '结果应向校级管理员返回随机初始密码');
|
|
assert.equal((await schoolAdmin.request('/api/admin/candidates')).data.candidates.length, candidatesBeforeBatch + 3, '最终批准应原子创建全部考生账户');
|
|
assert.equal((await admin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved' } })).response.status, 404, '重复审批不得再次生成账号');
|
|
const schoolBatchResult = await schoolAdmin.request('/api/admin/candidate-account-batches');
|
|
const returnedBatch = schoolBatchResult.data.batches.find(item => item.id === batchId);
|
|
assert.ok(returnedBatch.items.every(item => item.candidateNumber && item.initialPassword), '批准结果应回到校级管理员的批次详情');
|
|
const firstIssued = returnedBatch.items[0];
|
|
const batchLogin = await batchCandidate.request('/api/auth/login', { method: 'POST', body: { username: firstIssued.candidateNumber, password: firstIssued.initialPassword } });
|
|
assert.equal(batchLogin.data.user.mustChangePassword, true, '批量下发账户首次登录也必须强制修改密码');
|
|
|
|
const now = Date.now();
|
|
const hour = 60 * 60 * 1000;
|
|
const createExam = await admin.request('/api/admin/exams', {
|
|
method: 'POST',
|
|
body: {
|
|
name: '系统全流程测试考试', code: 'EX-TEST-FLOW', description: '自动化测试创建的多科目考试',
|
|
registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(),
|
|
examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(),
|
|
admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(),
|
|
location: '测试考点', status: 'published', subjects: ['语文', '数学', '外语']
|
|
}
|
|
});
|
|
assert.equal(createExam.response.status, 201);
|
|
assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试');
|
|
const exam = createExam.data.exam;
|
|
const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } });
|
|
assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划');
|
|
|
|
const draftResponse = await admin.request('/api/admin/exams', {
|
|
method: 'POST',
|
|
body: {
|
|
name: '待完善考试草稿', code: 'EX-TEST-DRAFT', description: '等待管理员继续配置',
|
|
registrationStart: new Date(now + 72 * hour).toISOString(), registrationEnd: new Date(now + 96 * hour).toISOString(),
|
|
examStart: new Date(now + 120 * hour).toISOString(), examEnd: new Date(now + 132 * hour).toISOString(),
|
|
admitDownloadStart: new Date(now + 100 * hour).toISOString(), admitDownloadEnd: new Date(now + 119 * hour).toISOString(),
|
|
location: '待定考点', status: 'draft', subjects: ['待定科目']
|
|
}
|
|
});
|
|
const editedDraft = await admin.request(`/api/admin/exams/${draftResponse.data.exam.id}`, {
|
|
method: 'PATCH',
|
|
body: { name: '已完善考试草稿', location: '测试中心', status: 'draft', subjects: ['语文', '数学'] }
|
|
});
|
|
assert.equal(editedDraft.response.status, 200, '管理员应可继续编辑考试草稿');
|
|
assert.deepEqual(editedDraft.data.exam.subjects.map(subject => subject.name), ['语文', '数学'], '草稿编辑应保存科目配置');
|
|
const refreshedAdminExams = await admin.request('/api/admin/exams');
|
|
const persistedDraft = refreshedAdminExams.data.exams.find(item => item.id === draftResponse.data.exam.id);
|
|
assert.equal(persistedDraft.name, '已完善考试草稿', '草稿修改应持久化');
|
|
assert.equal(persistedDraft.subjects.length, 2, '草稿科目修改应持久化');
|
|
|
|
const beforeApproval = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id] } });
|
|
assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试');
|
|
|
|
const candidates = await admin.request('/api/admin/candidates');
|
|
const profile = candidates.data.candidates.find(item => item.candidateNumber === candidateNumber);
|
|
assert.ok(profile, '管理员应能看到新注册考生');
|
|
assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料');
|
|
const schoolCandidates = await schoolAdmin.request('/api/admin/candidates');
|
|
assert.ok(schoolCandidates.data.candidates.some(item => item.id === profile.id), '校级管理员应看到本校考生');
|
|
const classCandidates = await classAdmin.request('/api/admin/candidates');
|
|
assert.ok(classCandidates.data.candidates.some(item => item.id === profile.id), '班级管理员应看到本班考生');
|
|
const classCannotReview = await classAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved' } });
|
|
assert.equal(classCannotReview.response.status, 403, '班级管理员不得审核考生资料');
|
|
|
|
const schoolApproveProfile = await schoolAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校学籍复核通过' } });
|
|
assert.equal(schoolApproveProfile.data.profile.status, 'pending', '校级初审后应进入超级管理员终审');
|
|
const approveProfile = await admin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考试中心终审通过' } });
|
|
assert.equal(approveProfile.data.profile.status, 'approved');
|
|
|
|
const submitRegistration = await candidate.request('/api/candidate/registrations', {
|
|
method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id, exam.subjects[2].id] }
|
|
});
|
|
assert.equal(submitRegistration.response.status, 201);
|
|
assert.equal(submitRegistration.data.registration.subjectIds.length, 2, '考生应可自主选择多个科目');
|
|
const registrationId = submitRegistration.data.registration.id;
|
|
|
|
const adminRegistrations = await admin.request('/api/admin/registrations');
|
|
const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId);
|
|
assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列');
|
|
|
|
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
|
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
|
assert.ok(registrationFlow, '报名应建立可追踪的审批实例');
|
|
const transfer = await schoolAdmin.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: 'usr_school_admin_2', note: '同级管理员协办' } });
|
|
assert.equal(transfer.data.workflow.assignee.id, 'usr_school_admin_2', '同级管理员之间应可转交流程');
|
|
const schoolApproveRegistration = await schoolAdmin2.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校报名初审通过' } });
|
|
assert.equal(schoolApproveRegistration.data.registration.status, 'pending', '校级初审后报名仍应待终审');
|
|
|
|
const allFlows = await admin.request('/api/admin/workflow-instances');
|
|
const supervisedFlow = allFlows.data.instances.find(item => item.id === registrationFlow.id);
|
|
assert.equal(supervisedFlow.currentStep, 2, '超级管理员应看到全部流程及当前节点');
|
|
const supervisedReturn = await admin.request(`/api/admin/workflow-instances/${registrationFlow.id}/supervise`, { method: 'PATCH', body: { currentStep: 1, assigneeId: 'usr_school_admin', note: '抽查后退回学校复核' } });
|
|
assert.equal(supervisedReturn.data.workflow.currentStep, 1, '超级管理员应可监督并退回流程节点');
|
|
await schoolAdmin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校再次复核通过' } });
|
|
const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格终审通过' } });
|
|
assert.equal(approveRegistration.data.registration.status, 'approved');
|
|
assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号');
|
|
|
|
const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
|
assert.ok(generateAdmit.data.admitCard.number, '管理员应能生成准考证号');
|
|
|
|
const candidateRegistrations = await candidate.request('/api/candidate/registrations');
|
|
const candidateRegistration = candidateRegistrations.data.registrations.find(item => item.id === registrationId);
|
|
assert.ok(candidateRegistration.admitCard, '考生端应看到已生成准考证');
|
|
|
|
const downloadAdmit = await candidate.request(`/api/candidate/registrations/${registrationId}/admit-card`);
|
|
assert.equal(downloadAdmit.response.status, 200, '规定时间内应可下载准考证');
|
|
assert.match(downloadAdmit.data, /测试考生新名/);
|
|
assert.match(downloadAdmit.response.headers.get('content-disposition') || '', /attachment/);
|
|
|
|
const futureExamResponse = await admin.request('/api/admin/exams', {
|
|
method: 'POST',
|
|
body: {
|
|
name: '准考证窗口限制测试', code: 'EX-TEST-WINDOW', description: '验证下载时间限制',
|
|
registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(),
|
|
examStart: new Date(now + 96 * hour).toISOString(), examEnd: new Date(now + 100 * hour).toISOString(),
|
|
admitDownloadStart: new Date(now + 48 * hour).toISOString(), admitDownloadEnd: new Date(now + 95 * hour).toISOString(),
|
|
location: '测试考点', status: 'published', subjects: ['综合能力']
|
|
}
|
|
});
|
|
const futureExam = futureExamResponse.data.exam;
|
|
const futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } });
|
|
const futureRegistrationId = futureRegistration.data.registration.id;
|
|
await schoolAdmin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } });
|
|
const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } });
|
|
assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号');
|
|
await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
|
const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`);
|
|
assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载');
|
|
|
|
const publishNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '系统测试成绩发布通知', summary: '测试通知将同步到首页', content: '系统测试考试成绩现已发布。', category: '成绩通知', pinned: true, status: 'published' } });
|
|
assert.equal(publishNotice.response.status, 201);
|
|
const refreshedHome = await anonymous.request('/api/public/home');
|
|
assert.ok(refreshedHome.data.notices.some(item => item.title === '系统测试成绩发布通知'), '管理员发布通知后首页应可见');
|
|
|
|
const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } });
|
|
assert.equal(publishResult.response.status, 200);
|
|
const results = await candidate.request('/api/candidate/results');
|
|
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
|
const classResults = await classAdmin.request('/api/admin/results');
|
|
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
|
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
|
|
|
const workflowDefinitions = await admin.request('/api/admin/workflows');
|
|
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');
|
|
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'center_change'), '流程设计应包含考点考场变更审批');
|
|
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'candidate_account_batch'), '流程设计应包含批量报名号申领审批');
|
|
const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } });
|
|
assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程');
|
|
|
|
console.log('✓ 公开首页与通知读取');
|
|
console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张分表)`);
|
|
console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离');
|
|
console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关');
|
|
console.log('✓ 多科目考试创建与考生自主选科报名');
|
|
console.log('✓ 审批流程设计、同级转交与超级管理员监督退回');
|
|
console.log('✓ 校级按班级批量申领、终审原子建号与结果返回');
|
|
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
|
|
console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出');
|
|
console.log('✓ 成绩录入、发布与考生查询');
|
|
} finally {
|
|
server.kill('SIGTERM');
|
|
await new Promise(resolveWait => server.once('exit', resolveWait));
|
|
await rm(testDb, { force: true });
|
|
await rm(`${testDb}-shm`, { force: true });
|
|
await rm(`${testDb}-wal`, { force: true });
|
|
}
|