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 { relationalTables } from '../database.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 response = await fetch(`${baseUrl}${path}`, { ...options, headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, body: options.body && typeof options.body !== 'string' ? 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() : await response.text(); return { response, data }; } }; } const admin = createClient(); const schoolAdmin = createClient(); const schoolAdmin2 = createClient(); const classAdmin = createClient(); const candidate = 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), '公开考试应包含多个科目'); const register = await candidate.request('/api/auth/register', { method: 'POST', body: { username: 'test_candidate', password: 'Test12345!', name: '测试考生', gender: '男', idNumber: '320101200801019999', phone: '13900009999', schoolId: 'school_hz1', classId: 'class_hz1_302' } }); assert.equal(register.response.status, 201, '考生应可自主注册'); const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: 'test_candidate', password: 'Test12345!' } }); assert.equal(loginCandidate.data.user.role, 'candidate'); const updateProfile = await candidate.request('/api/candidate/profile', { method: 'PUT', body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市测试区 1 号', emergencyContact: '测试家长', emergencyPhone: '13800008888' } }); assert.equal(updateProfile.response.status, 200, '考生应可自主维护完整资料'); 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, '考生不得访问管理接口'); 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'); 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, seatStart: 1, seatEnd: 30, 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, seatStart: 31, seatEnd: 55, 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 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.ok(numberRules.data.batchCandidates.some(item => item.id === 'reg_demo_2026'), '报名号页面应列出审核通过但缺少号码的记录'); const batchNumbers = await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: { examId: 'exam_autumn_2026', schoolId: 'school_hz1' } }); assert.equal(batchNumbers.response.status, 200, '超级管理员应可按考试和学校批量生成报名号'); assert.equal(batchNumbers.data.count, 1, '批量生成只处理筛选范围内缺失号码的记录'); assert.match(batchNumbers.data.registrations[0].registrationNumber, /^2026-HZ01-F-\d{4}$/); assert.equal((await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: { examId: 'exam_autumn_2026', schoolId: 'school_hz1' } })).response.status, 409, '重复批量执行不得覆盖已有报名号'); 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.username === 'test_candidate'); 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.match(approveRegistration.data.registration.registrationNumber, /^2026-HZ01-M-\d{4}$/, '终审通过后应按年份、学校、性别和流水号生成报名号'); 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: '学校通过' } }); await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } }); 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'), '流程设计应包含考点考场变更审批'); 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('✓ 成绩录入、发布与考生查询'); } 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 }); }