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 { buildCenterMaterialsWorkbook, 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 classAdmin2 = 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 clientSources = await Promise.all([ 'app.js', 'src/client/admin-views.mjs', 'src/client/candidate-views.mjs', 'src/client/public-views.mjs' ].map(file => readFile(resolve(root, file), 'utf8'))); const appSource = clientSources.join('\n'); for (const modulePath of ['/src/client/api.mjs', '/src/client/ui.mjs', '/src/client/public-views.mjs', '/src/client/candidate-views.mjs', '/src/client/admin-views.mjs']) { const moduleResponse = await anonymous.request(modulePath); assert.equal(moduleResponse.response.status, 200, `${modulePath} 应作为浏览器模块提供`); assert.match(moduleResponse.response.headers.get('content-type') || '', /text\/javascript/, `${modulePath} 应返回 JavaScript MIME 类型`); } assert.doesNotMatch(appSource, /onclick=["']event\.stopPropagation\(\)/, '弹窗不得截断内部按钮的委托点击事件'); assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭'); assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口'); assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口'); assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定'); 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); const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version; const resultLockTriggers = inspector.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'trg_results_lock_archived_%'").all().map(row => row.name); const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name); const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_subjects)').all().map(row => row.name); const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name); const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name); const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name); inspector.close(); assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); assert.equal(schemaVersion, 14, '考试归档应使用 v14 数据结构'); assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员'); assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩'); assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人'); assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照'); assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照'); assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值'); 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 classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).data.user.adminLevel, 'class'); assert.equal((await classAdmin2.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: 'Class123!', newPassword: 'ClassChanged123!' } })).response.status, 200, '管理员登录后应可修改自己的密码'); assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).response.status, 401, '管理员改密后旧密码应立即失效'); 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', provinceCode: '320000', cityCode: '320700', districtCode: '320706', 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: '海州市第一中学东区考点', provinceCode: '320000', cityCode: '320700', districtCode: '320706', 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('S3').value, '001', '考场代码应保留前导零'); assert.equal(centerWorkbook.worksheets[0].getCell('S3').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 导入考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', 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 archiveTarget = returnedBatch.items.find(item => item.classId === 'class_hz1_301'); const archiveCandidate = createClient(); assert.equal((await archiveCandidate.request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 200); assert.equal((await admin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } })).response.status, 403, '超级管理员不得代替学校归档考生账户'); assert.equal((await classAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } })).response.status, 403, '班级管理员不得批量归档考生账户'); const archivedAccounts = await schoolAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } }); assert.equal(archivedAccounts.response.status, 200, '校级管理员应可按班级归档考生账户'); assert.ok(archivedAccounts.data.count >= 2, '按班级归档应批量更新该班考生'); const archivedDirectory = await schoolAdmin.request('/api/admin/candidates'); const archivedProfile = archivedDirectory.data.candidates.find(item => item.candidateNumber === archiveTarget.candidateNumber); assert.equal(archivedProfile.accountArchived, true, '考生目录应显示归档状态'); assert.equal((await archiveCandidate.request('/api/auth/me')).data.user, null, '归档应立即使考生现有会话失效'); assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 401, '归档账户不得登录'); assert.equal((await admin.request(`/api/admin/candidates/${archivedProfile.id}/reset-password`, { method: 'POST' })).response.status, 409, '归档账户恢复前不得重置密码'); const restoredAccounts = await schoolAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'grade', scopeValue: '高三', archived: false } }); assert.equal(restoredAccounts.response.status, 200, '校级管理员应可按年级恢复考生账户'); const resetPassword = await admin.request(`/api/admin/candidates/${archivedProfile.id}/reset-password`, { method: 'POST' }); assert.equal(resetPassword.response.status, 200, '超级管理员应可为未归档考生重置密码'); assert.match(resetPassword.data.temporaryPassword, /^Reset-/, '重置服务应返回一次性临时密码'); assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 401, '重置后原密码应失效'); const resetLogin = await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: resetPassword.data.temporaryPassword } }); assert.equal(resetLogin.data.user.mustChangePassword, true, '使用重置临时密码登录后必须先改密'); assert.equal((await schoolAdmin.request(`/api/admin/candidates/${archivedProfile.id}`, { method: 'DELETE' })).response.status, 405, '任何管理员都不得删除考生账户'); 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: [ { name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passRule: 'fixed_score', passValue: 90 }, { name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 0, fullScore: 150, passScore: 90 }, { name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passRule: 'rank_percent', passValue: 60 } ] } }); assert.equal(createExam.response.status, 201); assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试'); assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线'); assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线'); 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', passPolicy: 'rank_percent', passValue: 30, subjects: [ { name: '语文', date: new Date(now + 120 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 20, fullScore: 120, passScore: 72 }, { name: '数学', date: new Date(now + 121 * hour).toISOString().slice(0, 10), start: '14:00', end: '16:00', fee: 20, fullScore: 180, passScore: 108 } ] } }); 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, '草稿科目修改应持久化'); assert.equal(persistedDraft.totalScore, 300, '考试总分应由结构化科目满分自动汇总'); assert.equal(persistedDraft.passPolicy, 'rank_percent', '考试应持久化排名百分比合格策略'); assert.equal(persistedDraft.passValue, 30, '合格策略数值应持久化'); assert.equal(persistedDraft.subjects[1].passScore, 108, '每科应独立保存满分与单科合格分'); 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 号', '详细门牌地址应独立保存'); assert.deepEqual( [profile.provinceCode, profile.cityCode, profile.districtCode, profile.provinceName, profile.cityName, profile.districtName], ['320000', '320700', '320706', '江苏省', '连云港市', '海州区'], '管理员应能审核考生自主填写且经过服务端规范化的省市区县信息' ); 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 assignedSchoolClient = registrationFlow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; const transferTargetId = registrationFlow.assignee.id === 'usr_school_admin_2' ? 'usr_school_admin' : 'usr_school_admin_2'; const transferTargetClient = transferTargetId === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; const transfer = await assignedSchoolClient.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: transferTargetId, note: '同级管理员协办' } }); assert.equal(transfer.data.workflow.assignee.id, transferTargetId, '同级管理员之间应可转交流程'); const schoolApproveRegistration = await transferTargetClient.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 arrangementContext = await admin.request('/api/admin/admission-arrangements'); assert.equal(arrangementContext.data.rules.length, 4, '系统应预置四种准考证号规则'); assert.deepEqual(arrangementContext.data.mixingScopes.map(item => item.code), ['class', 'school', 'district', 'city', 'province'], '应支持班、校、县区、市、省五级混编'); for (const scope of arrangementContext.data.mixingScopes) { const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: { mixingScope: scope.code, numberRuleId: arrangementContext.data.rules[0].id, seed: `scope-${scope.code}` } }); assert.equal(preview.response.status, 200, `${scope.name}应通过容量与同考点约束预检`); } for (const rule of arrangementContext.data.rules) { const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: { mixingScope: 'school', numberRuleId: rule.id, seed: `rule-${rule.code}` } }); assert.equal(preview.response.status, 200, `${rule.name}应能生成有效号码`); assert.equal(new Set(preview.data.samples.map(item => item.number)).size, preview.data.samples.length, `${rule.name}生成的样例号码不得重复`); } const arrangementBody = { mixingScope: 'school', numberRuleId: 'admit_rule_district_room_seat', seed: 'system-test-stable' }; const arrangementPreview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: arrangementBody }); assert.equal(arrangementPreview.response.status, 200, '正式写入前应可预检整场编排'); assert.ok(arrangementPreview.data.summary.subjectAssignmentCount >= arrangementPreview.data.summary.candidateCount, '预检应统计全部科次座位'); const generateAdmit = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody }); const generatedCard = generateAdmit.data.cards.find(item => item.registrationId === registrationId); assert.ok(generatedCard.number, '管理员应能按整场考试批量生成准考证号'); assert.ok(generatedCard.centerCode && generatedCard.centerAddress, '准考证应包含考点代码和完整地址'); assert.equal(new Set(generatedCard.assignments.map(item => item.centerId)).size, 1, '同一考生所有科目必须固定在同一考点'); assert.equal(generatedCard.assignments.length, submitRegistration.data.registration.subjectIds.length, '每个报考科目都必须有独立考场座位'); assert.ok(generatedCard.assignments.every(item => item.examRoomCode && item.roomName && item.roomCode && item.building && item.floor), '逐科安排必须同时保存考试考场序号、物理场地和楼栋楼层'); const codesByPhysicalRoom = Map.groupBy ? Map.groupBy(generateAdmit.data.cards.flatMap(card => card.assignments), item => item.roomId) : generateAdmit.data.cards.flatMap(card => card.assignments).reduce((groups, item) => groups.set(item.roomId, [...(groups.get(item.roomId) || []), item]), new Map()); assert.ok([...codesByPhysicalRoom.values()].every(items => new Set(items.map(item => item.examRoomCode)).size === 1), '同一场考试内,同一物理考场跨科目必须保持同一个考试考场序号'); assert.equal((await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: {} })).response.status, 410, '旧单人生成入口应明确停用'); 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.data, /考试考场序号/); assert.match(downloadAdmit.data, /考场通用名称 \/ 场地代码/); assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].building)); assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].floor)); assert.match(downloadAdmit.response.headers.get('content-disposition') || '', /attachment/); const schoolAdmitContext = await schoolAdmin.request('/api/admin/admission-arrangements'); assert.equal(schoolAdmitContext.response.status, 200, '校级管理员应有校内准考证入口'); assert.equal(schoolAdmitContext.data.canArrange, false, '校级管理员不能执行整场编排'); assert.ok(schoolAdmitContext.data.registrations.every(item => item.candidate.schoolId === 'school_hz1'), '校级准考证台账必须限制为本校考生'); const classAdmitContext = await classAdmin.request('/api/admin/admission-arrangements'); assert.equal(classAdmitContext.response.status, 200, '班级管理员应有本班准考证入口'); assert.ok(classAdmitContext.data.registrations.every(item => item.candidate.classId === 'class_hz1_302'), '班级准考证台账必须限制为本班考生'); const schoolBatchCards = await schoolAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`); assert.equal(schoolBatchCards.response.status, 200, '校级管理员应可批量下载校内准考证'); assert.match(schoolBatchCards.response.headers.get('content-disposition') || '', /attachment/); assert.match(schoolBatchCards.data, /考试考场序号/); const classBatchCards = await classAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`); assert.equal(classBatchCards.response.status, 200, '班级管理员应可批量下载本班准考证'); assert.match(classBatchCards.data, /测试考生新名/); const classInfoExport = await classAdmin.request(`/api/admin/admission-exports/info?examId=${exam.id}`); assert.equal(classInfoExport.response.status, 200, '班级管理员应可导出本班准考证信息台账'); const classInfoBook = new ExcelJS.Workbook(); await classInfoBook.xlsx.load(classInfoExport.data); const classInfoSheet = classInfoBook.getWorksheet('准考证信息'); assert.ok(classInfoSheet, '准考证信息导出应有独立工作表'); assert.equal(classInfoSheet.rowCount, generatedCard.assignments.length + 2, '准考证信息应按考生逐科导出'); assert.equal(classInfoSheet.getCell('D3').value, '测试考生新名'); assert.equal(classInfoSheet.getCell('O3').value, generatedCard.assignments[0].examRoomCode, '信息台账应明确导出考试考场序号'); assert.equal(classInfoSheet.getCell('P3').value, generatedCard.assignments[0].roomName, '信息台账应另列考场通用名称'); const schoolMaterials = await schoolAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`); assert.equal(schoolMaterials.response.status, 200, '校级管理员应可导出本校维护考点的现场物料'); const materialsBook = new ExcelJS.Workbook(); await materialsBook.xlsx.load(schoolMaterials.data); assert.deepEqual(materialsBook.worksheets.map(sheet => sheet.name), ['桌贴', '门贴', '考场签名单'], '考点物料应包含桌贴、门贴和考场签名单三张表'); const deskStickerSheet = materialsBook.getWorksheet('桌贴'); assert.ok(deskStickerSheet.rowCount >= 8, '每张方形桌贴应占用完整标签卡片区域'); assert.equal(deskStickerSheet.pageSetup.orientation, 'portrait', '方形桌贴应使用 A4 纵向打印'); assert.equal(deskStickerSheet.getRow(1).height, 32.5, '桌贴应使用固定行高形成方形标签'); assert.equal(deskStickerSheet.getColumn(1).width, deskStickerSheet.getColumn(3).width, '桌贴卡片各列应等宽'); assert.ok(deskStickerSheet.model.merges.includes('A1:C1'), '第一张桌贴应跨三列形成完整方形卡片'); assert.match(String(deskStickerSheet.getCell('A3').value), /考试考场序号/); assert.equal(deskStickerSheet.getCell('A4').text, generatedCard.assignments[0].examRoomCode, '桌贴应突出考试考场序号'); assert.match(String(deskStickerSheet.getCell('A5').value), new RegExp(generatedCard.assignments[0].seat), '桌贴应突出座位号'); assert.match(String(materialsBook.getWorksheet('门贴').getCell('A1').value), /考场门贴/); assert.equal((await classAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`)).response.status, 403, '班级管理员不得导出考点级现场物料'); const paginationRows = Array.from({ length: 7 }, (_, index) => ({ examCode: 'EX-QA', examName: '桌贴分页测试', subjectName: '语文', subjectDate: '2026-10-01', subjectTime: '09:00—11:00', centerCode: 'C01', centerName: '测试考点', examRoomCode: '001', roomName: '第一教室', roomCode: 'R01', building: '教学楼', floor: '1 层', seat: String(index + 1).padStart(2, '0'), cardNumber: `CARD${index + 1}`, candidateName: `考生${index + 1}`, schoolName: '测试学校', className: '测试班' })); const paginationBook = new ExcelJS.Workbook(); await paginationBook.xlsx.load(await buildCenterMaterialsWorkbook(paginationRows, '分页测试')); assert.equal(paginationBook.getWorksheet('桌贴').pageSetup.printArea, 'A1:G48', '超过六张桌贴时应扩展为两页固定打印区域'); assert.equal(paginationBook.getWorksheet('桌贴').getRow(32).height, 32.5, '第二页桌贴仍应保持相同方形标签高度'); 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; const futureFlow = (await schoolAdmin.request('/api/admin/workflow-instances')).data.instances.find(item => item.businessId === futureRegistrationId && item.status === 'pending'); const futureSchoolClient = futureFlow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; await futureSchoolClient.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, '同一考生参加不同考试必须保持相同报名号'); const futureArrangement = await admin.request(`/api/admin/exams/${futureExam.id}/admission-arrangement`, { method: 'POST', body: { mixingScope: 'district', numberRuleId: 'admit_rule_candidate_school_room_seat', seed: 'future-window-test' } }); assert.equal(futureArrangement.response.status, 200, '不同考试应分别保存独立编排方案'); 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 stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }])); const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile }); assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览'); assert.equal(stagedPreview.data.preview, true); assert.equal(stagedPreview.data.summary.valid, 1); assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库'); const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } }); assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩'); assert.equal(stagedCommit.data.count, 1); assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库'); 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); assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分'); assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200); const results = await candidate.request('/api/candidate/results'); assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询'); const resultSummary = results.data.summaries.find(item => item.examId === exam.id); assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分'); assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总'); assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格'); assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算'); 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 appealResults = results.data.results.filter(item => [exam.subjects[0].id, exam.subjects[2].id].includes(item.subjectId)); assert.equal(appealResults.length, 2, '测试考生应有两科可提交成绩复议'); const submittedAppeals = []; for (const result of appealResults) { const submitted = await candidate.request(`/api/candidate/results/${result.id}/appeals`, { method: 'POST', body: { reason: `${result.subjectName}成绩与个人估分差异较大,请复核答卷计分。` } }); assert.equal(submitted.response.status, 201, '考生应能对已发布成绩提交复议'); submittedAppeals.push({ result, workflow: submitted.data.workflow }); } assert.deepEqual(new Set(submittedAppeals.map(item => item.workflow.assignee.id)), new Set(['usr_class_admin', 'usr_class_admin_2']), '同班多位班级管理员的新增复议应均分'); const classFlows = await classAdmin.request('/api/admin/workflow-instances'); assert.equal(classFlows.response.status, 200, '班级管理员应有流程中心和审批权限'); assert.ok(classFlows.data.instances.filter(item => item.businessType === 'score_appeal' && item.status === 'pending').every(item => item.assignee.classId === 'class_hz1_302' && item.assignee.schoolId === 'school_hz1'), '班级步骤只能分配给该校该班管理员'); const schoolAppeals = []; for (const appeal of submittedAppeals) { const classClient = appeal.workflow.assignee.id === 'usr_class_admin_2' ? classAdmin2 : classAdmin; const classApproved = await classClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '班级已核验考生身份与科目' } }); assert.equal(classApproved.response.status, 200, '班级管理员应能审批分配给自己的成绩复议'); assert.equal(classApproved.data.workflow.currentStepDetail.adminLevel, 'school', '班级审批后应进入本校管理员步骤'); assert.equal(classApproved.data.workflow.assignee.schoolId, 'school_hz1', '校级步骤必须匹配考生所属学校'); schoolAppeals.push({ result: appeal.result, workflow: classApproved.data.workflow }); } assert.deepEqual(new Set(schoolAppeals.map(item => item.workflow.assignee.id)), new Set(['usr_school_admin', 'usr_school_admin_2']), '同校多位校级管理员的复议任务应均分'); for (const appeal of schoolAppeals) { const schoolClient = appeal.workflow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; const schoolApproved = await schoolClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校完成成绩复核' } }); assert.equal(schoolApproved.response.status, 200); assert.equal(schoolApproved.data.workflow.currentStepDetail.adminLevel, 'super', '校级复核后应进入考试中心终审'); const missingReviewedScore = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '遗漏复核分数' } }); assert.equal(missingReviewedScore.response.status, 400, '成绩复议终审批准时必须填写复核后分数'); const reviewedScore = appeal.result.subjectId === exam.subjects[0].id ? 128 : 89; const finalApproved = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewedScore, reviewNote: '考试中心终审完成' } }); assert.equal(finalApproved.data.workflow.status, 'approved', '成绩复议应可完整走完班级、学校和考试中心流程'); assert.equal(finalApproved.data.result.score, reviewedScore, '终审批准应在同一事务中更新复核后成绩'); assert.equal(finalApproved.data.result.grade, 'A+', '复议后等级应按当前同科排名重新判断'); if (appeal.result.subjectId === exam.subjects[2].id) assert.equal(finalApproved.data.result.qualified, true, '排名比例科目只判断复核后成绩所在当前排名区间'); } const appealedResults = await candidate.request('/api/candidate/results'); assert.ok(appealedResults.data.results.filter(item => appealResults.some(result => result.id === item.id)).every(item => item.appeal?.status === 'approved'), '考生端应显示每科复议结果'); assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[0].id).score, 128, '考生端应立即看到复议后的语文成绩'); assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[2].id).score, 89, '考生端应立即看到复议后的外语成绩'); assert.equal((await schoolAdmin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' })).response.status, 403, '只有超级管理员可以归档考试'); const archivedExam = await admin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' }); assert.equal(archivedExam.response.status, 200, '超级管理员应能归档考试'); assert.ok(archivedExam.data.exam.archivedAt, '归档应记录不可逆时间戳'); assert.equal(archivedExam.data.exam.status, 'closed', '归档考试应退出公开发布状态'); assert.equal((await admin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' })).response.status, 409, '归档操作不可重复或撤销'); assert.equal((await admin.request(`/api/admin/exams/${exam.id}`, { method: 'PATCH', body: { status: 'published' } })).response.status, 409, '归档后考试配置不得再修改'); assert.equal((await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody })).response.status, 409, '归档后不得重新编排准考证'); assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1, published: true } })).response.status, 409, '归档后手工录入不得修改成绩'); const lockInspector = new DatabaseSync(testDb); assert.throws(() => lockInspector.prepare('UPDATE results SET score = 1 WHERE id = ?').run(appealResults[0].id), /永久锁定/, '绕过接口直接写库也不得修改归档成绩'); lockInspector.close(); const archivedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 1, published: '发布' }])); const archivedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: archivedResultFile }); assert.equal(archivedPreview.response.status, 200, '归档成绩 Excel 仍应返回逐行预览'); assert.match(archivedPreview.data.rows[0].errors.join(''), /永久锁定/, '归档场次的 Excel 行必须标记为不可提交'); assert.equal((await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: archivedPreview.data.rows } })).response.status, 400, '归档后 Excel 批量提交不得修改成绩'); assert.equal((await candidate.request(`/api/candidate/results/${appealResults[0].id}/appeals`, { method: 'POST', body: { reason: '归档后再次申请复议' } })).response.status, 409, '归档后考生不得再发起成绩复议'); const archivedAdminResults = await admin.request('/api/admin/results'); assert.ok(archivedAdminResults.data.exams.find(item => item.id === exam.id)?.archivedAt, '管理端成绩中心应返回归档状态'); const archivedCandidateResults = await candidate.request('/api/candidate/results'); assert.ok(archivedCandidateResults.data.results.filter(item => item.examId === exam.id).every(item => item.archivedAt), '考生历史成绩应标记为归档并进入折叠区'); const archivedRegistrations = await candidate.request('/api/candidate/registrations'); assert.ok(archivedRegistrations.data.registrations.find(item => item.exam.id === exam.id)?.exam.archivedAt, '报名和准考证信息应同步进入归档折叠区'); const homeAfterArchive = await anonymous.request('/api/public/home'); assert.ok(!homeAfterArchive.data.exams.some(item => item.id === exam.id), '归档考试不得继续出现在公开考试列表'); 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'), '流程设计应包含批量报名号申领审批'); assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'score_appeal'), '流程设计应包含考生成绩复议'); assert.match(appSource, /renderAdmin, workflowStepEditor/, '添加流程步骤事件必须取得视图模块导出的编辑器,避免未定义错误'); 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('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出'); console.log('✓ 成绩录入、发布与考生查询'); 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 }); }