Files
EIS-dotnet/tests/system.test.mjs
T
biss f3ffc18aae 新增 [Eis.Tools (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Tools/Program.cs:1),支持 database init/reset/seed。
数据库维护、安全校验和事务逻辑位于 [DatabaseMaintenanceService.cs (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs:1)。
内置完整演示数据:1200 名考生、10800 条成绩、2404 条招生记录。
删除旧 import-test-data.mjs 和 reset-dev-database.mjs 运行入口。
Docker 镜像同时包含 Web 和 /app/tools/Eis.Tools.dll。
[package.json (line 11)](C:/Users/BI/Documents/EIS-dotnet/package.json:11) 原数据库命令已改为调用 .NET。
新增统一发布脚本:[publish.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/publish.ps1:1)。
2026-07-23 16:25:40 +08:00

1269 lines
132 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process';
import { pbkdf2Sync, randomBytes } from 'node:crypto';
import { readFile, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { createServer as createNetServer } from 'node:net';
import assert from 'node:assert/strict';
import ExcelJS from 'exceljs';
import { createDatabase, relationalTables } from '../database.mjs';
import { createBaseDatabase } from '../src/data/base.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs';
import { mysqlSchema } from '../src/database/schema.mjs';
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
import { totpAtStep } from '../src/security/totp.mjs';
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
import { admissionNoticeCode } from '../src/security/document-verification.mjs';
const root = resolve(process.cwd());
assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果');
const port = await new Promise((resolvePort, rejectPort) => {
const probe = createNetServer();
probe.once('error', rejectPort);
probe.listen(0, '127.0.0.1', () => {
const selectedPort = probe.address().port;
probe.close(error => error ? rejectPort(error) : resolvePort(selectedPort));
});
});
const baseUrl = `http://127.0.0.1:${port}`;
const testDb = resolve(root, 'data', 'test-db.sqlite');
const mysqlRuleSchemas = mysqlSchema.filter(statement => /CREATE TABLE IF NOT EXISTS (?:admission_number_rules|number_rules)\b/.test(statement));
assert.equal(mysqlRuleSchemas.length, 2, 'MySQL 应包含两张号码规则表');
for (const statement of mysqlRuleSchemas) {
assert.match(statement, /`separator` VARCHAR\(10\)/, 'MySQL 保留字 separator 必须作为标识符转义');
}
const mysqlRegistrationSchema = mysqlSchema.find(statement => /CREATE TABLE IF NOT EXISTS registrations\b/.test(statement));
assert.match(mysqlRegistrationSchema || '', /paid_at VARCHAR\(35\).*paid_by VARCHAR\(64\)/s, 'MySQL 报名表应保存缴费确认时间和班级负责人');
const mysqlWorkflowSchema = mysqlSchema.find(statement => /CREATE TABLE IF NOT EXISTS workflow_definitions\b/.test(statement));
assert.match(mysqlWorkflowSchema || '', /'score_appeal'/, 'MySQL 工作流枚举应包含成绩复议');
const mysqlTableOrder = new Map(mysqlSchema.map((statement, index) => [
statement.match(/^CREATE TABLE IF NOT EXISTS\s+([a-z0-9_]+)/i)?.[1], index
]));
for (const [table, index] of mysqlTableOrder) {
const statement = mysqlSchema[index];
for (const match of statement.matchAll(/REFERENCES\s+([a-z0-9_]+)/gi)) {
assert.ok(mysqlTableOrder.has(match[1]) && mysqlTableOrder.get(match[1]) <= index, `MySQL 表 ${table} 的外键依赖必须先创建`);
}
}
const mysqlAdapterSource = await readFile(resolve(root, 'src', 'database', 'mysql-adapter.mjs'), 'utf8');
assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'MySQL 8.4 不支持 ADD COLUMN IF NOT EXISTS');
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19, 20\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v20 结构并重建更旧或未完成的开发结构');
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
const databaseToolSource = await readFile(resolve(root, 'src', 'Eis.Tools', 'Program.cs'), 'utf8');
const databaseMaintenanceSource = await readFile(resolve(root, 'src', 'Eis.Infrastructure', 'Data', 'DatabaseMaintenanceService.cs'), 'utf8');
assert.match(databaseToolSource, /EnvironmentFile\.Load\(applicationRoot\)/, '.NET 数据库工具应读取项目 .env');
assert.match(databaseToolSource, /case "--sqlite".*case "--mysql"/s, '.NET 数据库工具应支持显式选择 MySQL 或 SQLite');
assert.match(databaseToolSource, /--confirm-target/, '破坏性数据库命令必须精确确认目标');
assert.match(databaseMaintenanceSource, /nonEmpty\.Count > 0 && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖');
assert.match(databaseMaintenanceSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例数据替换应在受控外键环境中执行');
assert.match(databaseMaintenanceSource, /BeginTransactionAsync.*ImportDemoSeedAsync.*CommitAsync/s, 'MySQL 样例数据应在同一事务内清理并写入');
assert.match(databaseMaintenanceSource, /DatabaseMaintenanceMode\.Reset.*SeedBaseStateAsync/s, '测试结束后应支持恢复空业务系统');
assert.match(databaseMaintenanceSource, /recognizedDemo/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
assert.match(databaseMaintenanceSource, /SchemaVersion = 20/, '数据库导入工具应使用当前结构版本');
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
assert.equal(baseState.meta.version, CURRENT_SCHEMA_VERSION, '空库初始状态必须使用当前结构版本');
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');
assert.equal(baseState.registrations.length, 0, '正常首次建库不得预置报名');
const passwordProbeState = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
assert.deepEqual(new Set(passwordProbeState.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置测试账号应统一使用密码 12345678');
assert.ok(passwordProbeState.users.every(user => !user.mustChangePassword), '预置测试账号登录后不应强制修改统一密码');
await rm(testDb, { force: true });
await rm(`${testDb}-shm`, { force: true });
await rm(`${testDb}-wal`, { force: true });
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
return `${salt}:${hash}`;
}
// 系统测试显式准备测试库;服务启动本身只会初始化空业务库。
process.env.DATABASE_CLIENT = 'sqlite';
process.env.SQLITE_PATH = testDb;
const seededTestDatabase = await createDatabase({
root,
seed: () => {
// 全量 1200 人规模由 seed.test.mjs 单独验证;端到端流程使用较小样本控制运行时间。
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword, candidateCount: 360 });
const currentSuperAdmin = state.users.find(user => user.role === 'admin' && user.adminLevel === 'super');
state.users.push({
...currentSuperAdmin,
id: 'admin_legacy_super',
username: 'legacy_super',
adminLevel: null,
displayName: '旧版超级管理员'
});
return state;
}
});
await seededTestDatabase.close();
const server = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', REDIS_URL: '', TOTP_ENCRYPTION_KEY: 'test-only-totp-encryption-key-32-characters', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
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 legacyAdmin = createClient();
const schoolAdmin = createClient();
const schoolAdmin2 = createClient();
const classAdmin = createClient();
const classAdmin2 = createClient();
const candidate = createClient();
const selfCandidate = createClient();
const batchCandidate = createClient();
const admissionSchoolClient = 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/admission-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, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口');
assert.match(appSource, /data-action="bulk-placement-review"/, '招生学校投档审核应提供批量处理入口');
assert.match(appSource, /data-action="toggle-admission-account"/, '超级管理员应有招生学校账户启停入口');
assert.match(appSource, /data-action="reset-admission-account-password"/, '超级管理员应有招生学校账户密码重置入口');
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);
const schoolColumns = inspector.prepare('PRAGMA table_info(schools)').all().map(row => row.name);
const profileColumns = inspector.prepare('PRAGMA table_info(candidate_profiles)').all().map(row => row.name);
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count;
const seededExamCount = inspector.prepare('SELECT COUNT(*) AS count FROM exams').get().count;
const seededCandidateCount = inspector.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'candidate'").get().count;
const seededRegistrationCounts = inspector.prepare(`
SELECT status, payment_status, COUNT(*) AS count
FROM registrations
GROUP BY status, payment_status
`).all();
const seededArrangementCount = inspector.prepare('SELECT COUNT(*) AS count FROM exam_arrangement_plans').get().count;
const seededAdmitCardCount = inspector.prepare('SELECT COUNT(*) AS count FROM admit_cards').get().count;
const examPartitions = inspector.prepare('SELECT * FROM exam_data_partitions ORDER BY exam_id').all();
const schoolPartitions = inspector.prepare('SELECT * FROM school_student_partitions ORDER BY school_id').all();
const examPartitionCoverage = examPartitions.map(partition => {
for (const name of [partition.candidates_table, partition.admissions_table, partition.results_table, partition.centers_table]) {
assert.match(name, /^[a-z][a-z0-9_]{0,63}$/, '考试分表名必须是安全的数据库标识符');
assert.ok(tableNames.includes(name), `考试专属表 ${name} 必须真实存在`);
}
return {
examId: partition.exam_id,
candidates: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.candidates_table}"`).get().count,
expectedCandidates: inspector.prepare('SELECT COUNT(*) AS count FROM registrations WHERE exam_id = ?').get(partition.exam_id).count,
admissions: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.admissions_table}"`).get().count,
expectedAdmissions: inspector.prepare(`SELECT COUNT(*) AS count FROM registration_subjects selected
JOIN registrations registration ON registration.id = selected.registration_id WHERE registration.exam_id = ?`).get(partition.exam_id).count,
results: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.results_table}"`).get().count,
expectedResults: inspector.prepare(`SELECT COUNT(*) AS count FROM results result
JOIN registrations registration ON registration.id = result.registration_id WHERE registration.exam_id = ?`).get(partition.exam_id).count
};
});
const schoolPartitionCoverage = schoolPartitions.map(partition => {
assert.match(partition.students_table, /^[a-z][a-z0-9_]{0,63}$/, '学校学生分表名必须是安全的数据库标识符');
assert.ok(tableNames.includes(partition.students_table), `学校学生专属表 ${partition.students_table} 必须真实存在`);
return {
schoolId: partition.school_id,
students: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.students_table}"`).get().count,
expectedStudents: inspector.prepare(`SELECT COUNT(*) AS count FROM users user
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?`).get(partition.school_id).count
};
});
inspector.close();
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
assert.equal(schemaVersion, 20, '指标资格、资格公示与分数线公告应使用 v20 数据结构');
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
assert.ok(schoolPartitions.length > 0, '每所学校都应登记学生专属物理表');
assert.equal(schoolPartitions.length, seededSchoolCount, '学校学生分表登记不得缺漏');
assert.ok(schoolPartitionCoverage.every(item => item.students === item.expectedStudents), '学校学生专属表应仅保存本校学生且数量一致');
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(['totp_enabled', 'totp_secret_encrypted', 'totp_recovery_codes', 'totp_last_used_step'].every(column => userColumns.includes(column)), '账户应保存加密 TOTP 状态、恢复码哈希和防重放时间片');
assert.ok(['is_source_school', 'is_admission_school'].every(column => schoolColumns.includes(column)), '学校档案应统一保存生源校和招生校类型');
assert.ok(['specialty_category', 'specialty_type'].every(column => profileColumns.includes(column)), '考生档案应保存特长生大类和小类');
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
assert.ok(registrationColumns.includes('feature_score'), '每场考试报名应有独立且默认 0 分的特征分');
assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校');
assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生');
assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'unpaid' && item.count >= 90), '测试数据应包含大量已报名未缴费记录');
assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'paid' && item.count >= 90), '测试数据应包含大量已报名且缴费记录');
assert.equal(seededArrangementCount, 0, '测试数据不得预置考场编排计划');
assert.equal(seededAdmitCardCount, 0, '测试数据不得预置准考证或考场座位');
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, '自主注册默认应关闭');
assert.deepEqual(publicHome.data.organization, { name: '环境变量考试中心', code: 'ENV-TEST', phone: '0518-1234 5678', address: '测试地址 1 号', email: 'service@example.test' }, '公开机构名称及联系方式应从环境变量读取');
assert.equal(publicHome.data.siteCopy.heroTitle, '一次配置,', '首页主标语应从环境变量读取');
assert.equal(publicHome.data.siteCopy.heroHighlight, '统一首页文案。', '首页强调标语应从环境变量读取');
assert.equal(publicHome.data.siteCopy.footerNotice, '环境变量页脚提示', '首页页脚提示应从环境变量读取');
const ckeditorAsset = await anonymous.request('/vendor/ckeditor5/ckeditor5.js');
assert.equal(ckeditorAsset.response.status, 200, '服务端应提供自托管 CKEditor 浏览器包');
assert.match(ckeditorAsset.data, /ImageInsertViaUrl/, '自托管 CKEditor 应包含图片 URL 插件');
const ckeditorStyles = await anonymous.request('/vendor/ckeditor5/ckeditor5.css');
assert.equal(ckeditorStyles.response.status, 200, '服务端应提供自托管 CKEditor 样式');
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: '12345678' } });
assert.equal(loginAdmin.data.user.role, 'admin');
assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员');
assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '账号应默认关闭 TOTP');
const totpSetup = await admin.request('/api/auth/totp/setup', { method: 'POST', body: { currentPassword: '12345678' } });
assert.equal(totpSetup.response.status, 200, '当前密码验证通过后应可开始绑定 TOTP');
assert.match(totpSetup.data.qrCode, /^data:image\/png;base64,/, 'TOTP 二维码应在服务端本地生成为 PNG data URL');
assert.match(totpSetup.data.uri, /^otpauth:\/\/totp\//, '绑定响应应提供标准 otpauth URI');
const setupStep = Math.floor(Date.now() / 1000 / 30);
const totpEnable = await admin.request('/api/auth/totp/enable', { method: 'POST', body: { code: totpAtStep(totpSetup.data.secret, setupStep) } });
assert.equal(totpEnable.response.status, 200, '正确动态验证码应完成 TOTP 绑定');
assert.equal(totpEnable.data.recoveryCodes.length, 8, '启用后应一次性签发 8 个恢复码');
assert.equal(totpEnable.data.user.totpEnabled, true, '安全用户信息应公开 TOTP 开启状态但不公开密钥');
assert.equal(totpEnable.data.user.totpSecretEncrypted, undefined, 'API 不得返回加密后的 TOTP 密钥');
const originalRecoveryCodes = totpEnable.data.recoveryCodes;
await admin.request('/api/auth/logout', { method: 'POST' });
const totpPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
assert.equal(totpPasswordLogin.data.requiresTotp, true, '启用后密码验证不得直接创建登录会话');
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: '000000' } })).response.status, 401, '错误动态验证码应被拒绝');
const loginStep = Math.floor(Date.now() / 1000 / 30) + 1;
const totpLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: totpAtStep(totpSetup.data.secret, loginStep) } });
assert.equal(totpLogin.response.status, 200, '验证器动态验证码应完成第二步登录');
await admin.request('/api/auth/logout', { method: 'POST' });
const recoveryPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
const recoveryLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: recoveryPasswordLogin.data.challenge, code: originalRecoveryCodes[0] } });
assert.equal(recoveryLogin.response.status, 200, '恢复码应可替代动态验证码登录');
assert.equal(recoveryLogin.data.usedRecoveryCode, true, '恢复码登录应明确提醒用户');
assert.equal((await admin.request('/api/auth/totp')).data.recoveryCodesRemaining, 7, '使用后的恢复码应立即失效并减少剩余数量');
await admin.request('/api/auth/logout', { method: 'POST' });
const reusedRecoveryPassword = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[0] } })).response.status, 401, '恢复码不得重复使用');
assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[1] } })).response.status, 200, '同一挑战剩余尝试次数内应允许改用有效恢复码');
const regeneratedRecovery = await admin.request('/api/auth/totp/recovery-codes', { method: 'POST', body: { currentPassword: '12345678', code: originalRecoveryCodes[2] } });
assert.equal(regeneratedRecovery.data.recoveryCodes.length, 8, '通过二次验证后应可轮换全部恢复码');
const disableTotp = await admin.request('/api/auth/totp/disable', { method: 'POST', body: { currentPassword: '12345678', code: regeneratedRecovery.data.recoveryCodes[0] } });
assert.equal(disableTotp.response.status, 200, '当前密码和第二因素均通过后应可关闭 TOTP');
assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '关闭后应清空 TOTP 状态');
await admin.request('/api/auth/logout', { method: 'POST' });
assert.equal((await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } })).data.user.role, 'admin', '关闭后密码登录应恢复为单步会话');
const loginLegacyAdmin = await legacyAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'legacy_super', password: '12345678' } });
assert.equal(loginLegacyAdmin.data.user.adminLevel, 'super', '未保存层级的旧版管理员登录后应规范化为超级管理员');
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: '12345678' } })).data.user.adminLevel, 'school');
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: '12345678' } })).data.user.adminLevel, 'school');
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: '12345678' } })).data.user.adminLevel, 'class');
assert.equal((await classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: '12345678' } })).data.user.adminLevel, 'class');
assert.equal((await classAdmin2.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: '12345678', newPassword: 'ClassChanged123!' } })).response.status, 200, '管理员登录后应可修改自己的密码');
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: '12345678' } })).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', specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyCertificate: 'ART-2026-001' }
});
assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料');
assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成');
assert.equal(updateProfile.data.profile.status, 'pending', '完整资料应进入审核');
assert.equal(updateProfile.data.profile.specialtyCategory, 'arts', '考生资料应保存艺术大类资格');
assert.equal(updateProfile.data.profile.specialtyType, 'fine_arts', '考生资料应保存对应的美术小类资格');
const mismatchedSpecialty = await candidate.request('/api/candidate/profile', { method: 'PUT', body: { ...updateProfile.data.profile, schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', specialtyCategory: 'arts', specialtyType: 'track_field' } });
assert.equal(mismatchedSpecialty.response.status, 400, '艺术大类不得选择体育小类');
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 schoolDirectory = await admin.request('/api/admin/schools');
assert.equal(schoolDirectory.response.status, 200, '超级管理员应有学校管理入口');
assert.ok(schoolDirectory.data.schools.every(item => Number.isInteger(item.classCount) && Number.isInteger(item.adminCount)), '学校目录应汇总班级和管理员数量');
assert.equal((await schoolAdmin.request('/api/admin/schools')).response.status, 403, '校级管理员不得跨校维护学校档案');
const createSchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市第四中学', code: 'hz04', address: '海州市测试区学校路 4 号', active: true } });
assert.equal(createSchool.response.status, 201, '超级管理员应能创建学校');
assert.equal(createSchool.data.school.code, 'HZ04', '学校代码应规范化为大写');
assert.equal(createSchool.data.school.isSourceSchool, true, '新建学校默认兼容生源校职责');
assert.equal(createSchool.data.school.isAdmissionSchool, true, '新建学校默认兼容招生校职责');
const createdSchoolId = createSchool.data.school.id;
const admissionOnlySchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市招生实验学校', code: 'HZ-ADMISSION', isSourceSchool: false, isAdmissionSchool: true, active: true } });
assert.equal(admissionOnlySchool.response.status, 201, '学校管理应支持只设置为招生校');
assert.equal(admissionOnlySchool.data.school.isSourceSchool, false);
assert.ok(!(await anonymous.request('/api/public/home')).data.schools.some(item => item.id === admissionOnlySchool.data.school.id), '仅招生校不得出现在考生生源学校选择中');
const admissionAccount = await admin.request('/api/admin/admission-school-accounts', { method: 'POST', body: { schoolId: admissionOnlySchool.data.school.id, username: 'admission_only_test', password: 'Admission123!', displayName: '招生实验校招办' } });
assert.equal(admissionAccount.response.status, 201, '招生校应可创建招生学校账号');
assert.equal((await admissionSchoolClient.request('/api/auth/login', { method: 'POST', body: { username: 'admission_only_test', password: 'Admission123!' } })).response.status, 200, '招生学校账号应可登录独立工作台');
assert.equal((await admin.request('/api/admin/schools', { method: 'POST', body: { name: '重复代码学校', code: 'HZ04' } })).response.status, 409, '学校代码必须唯一');
const disableSchool = await admin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { name: '海州市第四实验中学', active: false } });
assert.equal(disableSchool.response.status, 200, '超级管理员应能编辑和停用学校');
assert.equal(disableSchool.data.school.active, false);
assert.equal(disableSchool.data.school.name, '海州市第四实验中学');
assert.equal((await schoolAdmin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { active: true } })).response.status, 403, '校级管理员不得启停学校');
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 managedAdmin = adminDirectory.data.admins.find(item => item.username === 'school_admin_2');
assert.ok(managedAdmin, '管理员台账应返回可管理的校级管理员');
assert.equal((await admin.request(`/api/admin/admins/${adminDirectory.data.admins.find(item => item.username === 'admin')?.id}`, { method: 'PATCH', body: { active: false } })).response.status, 409, '超级管理员不能停用当前正在使用的自己');
assert.equal((await admin.request(`/api/admin/admins/${managedAdmin.id}`, { method: 'PATCH', body: { active: false } })).response.status, 200, '超级管理员应能停用其他管理员账户');
assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, false, '管理员台账应同步显示停用状态');
const adminPasswordReset = await admin.request(`/api/admin/admins/${managedAdmin.id}/reset-password`, { method: 'POST' });
assert.equal(adminPasswordReset.response.status, 200, '超级管理员应能重置其他管理员密码');
assert.match(adminPasswordReset.data.temporaryPassword, /^Reset-/, '管理员密码重置应返回一次性临时密码');
assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, true, '重置密码应重新启用目标管理员账户');
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: managedAdmin.username, password: adminPasswordReset.data.temporaryPassword } })).response.status, 200, '被重置的管理员应可使用临时密码重新登录');
const schoolCenters = await schoolAdmin.request('/api/admin/centers');
assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点');
const legacySuperCenters = await legacyAdmin.request('/api/admin/centers');
assert.ok(legacySuperCenters.data.centers.some(item => item.schoolId === 'school_hz3'), '旧版超级管理员应能跨校读取学校维护的正式考点');
assert.ok(legacySuperCenters.data.centers.flatMap(item => item.rooms).some(item => item.centerId === 'center_hz3'), '旧版超级管理员应能读取其他学校维护的结构化考场');
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 legacyPendingCenters = await legacyAdmin.request('/api/admin/centers');
const legacyPendingCenter = legacyPendingCenters.data.changeRequests.find(item => item.id === newCenter.data.changeRequest.id);
assert.equal(legacyPendingCenter?.rooms[0]?.code, 'E001', '旧版超级管理员应能读取学校提交的待审批考点与考场快照');
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 legacyApprovedCenter = (await legacyAdmin.request('/api/admin/centers')).data.centers.find(item => item.code === 'HZ01-EAST');
assert.equal(legacyApprovedCenter?.rooms[0]?.code, 'E001', '旧版超级管理员应能读取学校维护并审批生效的考点与考场');
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 admittedWorkbook = new ExcelJS.Workbook();
await admittedWorkbook.xlsx.load(await buildWorkbook('admitted_candidates', [{ candidateNumber: '20260001', name: '录取考生', featureScore: 87.5, totalScore: 650, admittedSchool: '第一中学', categoryName: '艺术特长生', preferenceOrder: 1 }]));
const admittedSheet = admittedWorkbook.getWorksheet('录取考生');
assert.equal(admittedSheet.getCell('A3').value, '20260001', '录取考生 Excel 应包含报名号');
assert.ok(admittedSheet.getRow(2).values.includes('特征分'), '录取考生 Excel 应单列特征分');
assert.ok(admittedSheet.getRow(2).values.includes('录取学校'), '录取考生 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: 20, 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: 20, fullScore: 150, passScore: 90 },
{ name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 25, 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 admissionSettingResult = await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5, maxSubmissions: 1 } });
assert.equal(admissionSettingResult.response.status, 200, `超级管理员应能按考试启用志愿功能并设置填报次数:${JSON.stringify(admissionSettingResult.data)}\n${serverError}`);
const structuredPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, note: '结构化计划测试', categories: [
{ code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] },
{ code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] }
] } });
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts');
assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划');
const publicPlanAnnouncements = await anonymous.request('/api/public/announcements');
const publicPlan = publicPlanAnnouncements.data.plans.find(item => item.id === structuredPlan.data.plan.id);
assert.ok(publicPlan, '招生计划审核通过后应立即自动进入公开公示接口');
assert.equal(publicPlan.schoolName, '海州市招生实验学校', '招生计划公示应标明招生学校');
assert.deepEqual(publicPlan.rows.map(item => item.quota), [20, 4], '招生计划公示应保留各类别计划人数');
const noticeControlList = await admin.request('/api/admin/notices');
const controlledPlan = noticeControlList.data.publications.find(item => item.sourceType === 'plan' && item.id === structuredPlan.data.plan.id);
assert.ok(controlledPlan?.visible, '自动发布的招生计划应出现在通知发布管理页并默认显示');
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: false } })).response.status, 200, '通知发布管理页应能隐藏系统招生计划公示');
const hiddenPlanAnnouncements = await anonymous.request('/api/public/announcements');
assert.equal(hiddenPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), false, '隐藏系统公示后公开通知目录不得继续返回该招生计划');
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: true } })).response.status, 200, '已隐藏的系统招生计划公示应能重新显示');
const restoredPlanAnnouncements = await anonymous.request('/api/public/announcements');
assert.ok(restoredPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), '重新显示后招生计划应恢复到公开通知目录');
assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格');
const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
const qualificationExam = qualificationLedger.data.exams.find(item => item.examId === exam.id);
assert.ok(qualificationExam?.qualificationStatus.rows.length, '生源校学校管理员应看到本校待确认考生');
const bulkQualification = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/bulk`, { method: 'PUT', body: { userIds: qualificationExam.qualificationStatus.rows.map(row => row.userId), eligible: false } });
assert.equal(bulkQualification.response.status, 200, '生源校应能多选后一键批量确认指标分配资格');
assert.equal(bulkQualification.data.count, qualificationExam.qualificationStatus.rows.length, '批量确认应返回实际更新人数');
assert.equal(bulkQualification.data.published, true, '批量确认覆盖全校考生时应自动完成公示');
const candidateQualificationRow = qualificationExam.qualificationStatus.rows.find(row => row.registrationNumber === candidateNumber);
const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${candidateQualificationRow.userId}`, { method: 'PUT', body: { eligible: true } });
assert.equal(confirmation.response.status, 200, '批量确认后仍应支持逐人修正资格');
const completedQualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
assert.equal(completedQualificationLedger.data.exams.find(item => item.examId === exam.id).qualificationStatus.complete, true, '本校全部考生确认后应自动完成资格公示');
const publicQualification = await anonymous.request('/api/public/announcements');
const qualificationPublication = publicQualification.data.qualifications.find(item => item.examId === exam.id && item.schoolName === '海州市第一中学');
assert.ok(qualificationPublication, '资格全部确认后应自动出现在独立公开公告接口');
assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格');
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
const placementCandidates = qualificationExam.qualificationStatus.rows.slice(0, 3);
assert.equal(placementCandidates.length, 3, '批量投档测试至少需要三名候选考生');
const reportedCandidate = createClient();
const reportedCandidateLogin = await reportedCandidate.request('/api/auth/login', { method: 'POST', body: { username: placementCandidates[0].registrationNumber, password: '12345678' } });
assert.equal(reportedCandidateLogin.response.status, 200, '报到补录边界测试应能登录正式录取考生账号');
const placementIds = placementCandidates.map((row, index) => `placement_bulk_test_${index + 1}`);
const placementCreatedAt = new Date().toISOString();
const placementWriter = new DatabaseSync(testDb);
const insertPlacement = placementWriter.prepare(`
INSERT INTO admission_records (id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at)
VALUES (?, 'placement', ?, ?, ?, 'school_review', ?, ?, ?)
`);
for (const [index, row] of placementCandidates.entries()) {
insertPlacement.run(
placementIds[index], exam.id, row.userId, admissionOnlySchool.data.school.id,
JSON.stringify({ categoryCode: index === 2 ? 'arts' : 'general', categoryName: index === 2 ? '美术特长生' : '普通生', totalScore: 620 - index, preferenceOrder: index + 1 }),
placementCreatedAt, placementCreatedAt
);
}
placementWriter.prepare("UPDATE admission_records SET status = 'school_review' WHERE kind = 'setting' AND exam_id = ?").run(exam.id);
placementWriter.close();
const placementReviewLedger = await admissionSchoolClient.request('/api/admission/placements');
const seededPlacements = placementReviewLedger.data.placements.filter(item => placementIds.includes(item.id));
assert.equal(seededPlacements.length, 3, '招生学校投档台账应返回可供筛选、搜索的完整候选记录');
assert.ok(seededPlacements.every(item => item.examName === exam.name && item.candidate.registrationNumber), '投档台账应包含考试名称和考生报名号');
const bulkAccept = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept', note: '批量核验通过' } });
assert.equal(bulkAccept.response.status, 200, '招生学校应能批量接收多名投档考生');
assert.equal(bulkAccept.data.count, 2, '批量接收应返回实际处理人数');
const shortBulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '材料不符' } });
assert.equal(shortBulkWithdrawal.response.status, 400, '批量退档必须填写充分的特殊理由');
const bulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '专项资格证明材料复核不通过' } });
assert.equal(bulkWithdrawal.response.status, 200, '招生学校应能批量申请退档');
const processedPlacementLedger = await admissionSchoolClient.request('/api/admission/placements');
assert.ok(processedPlacementLedger.data.placements.filter(item => placementIds.slice(0, 2).includes(item.id)).every(item => item.status === 'admitted'), '批量接收后所选记录应全部进入拟录取状态');
assert.equal(processedPlacementLedger.data.placements.find(item => item.id === placementIds[2]).status, 'withdrawal_pending', '批量退档后记录应进入上级审核状态');
assert.equal((await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept' } })).response.status, 409, '已处理记录不得被重复批量审核');
assert.equal((await admin.request(`/api/admin/admission-withdrawals/${placementIds[2]}`, { method: 'PATCH', body: { approved: true, reviewNote: '同意退档' } })).response.status, 200, '超级管理员应先办结退档再签发录取通知书');
const finalizedAdmission = await admin.request(`/api/admin/admissions/${exam.id}/finalize`, { method: 'POST' });
assert.equal(finalizedAdmission.response.status, 200, '超级管理员应能签发带编号的录取通知书并开启报到');
assert.equal(finalizedAdmission.data.admittedCount, 2, '正式签发人数应与学校接收人数一致');
const roundAdmissionAnnouncements = await anonymous.request('/api/public/announcements');
const roundAdmissionPublication = roundAdmissionAnnouncements.data.admissions.find(item => item.examId === exam.id && item.round === 1);
assert.ok(roundAdmissionPublication, '每轮录取通知书签发后应立即自动生成本轮录取名单公示');
assert.equal(roundAdmissionPublication.rows.length, 2, '本轮录取公示应固定保存本轮全部正式录取考生');
assert.ok(roundAdmissionPublication.title.includes('第 1 轮录取名单公示'), '本轮公示标题应明确标注录取轮次');
const homeAfterRoundAdmission = await anonymous.request('/api/public/home');
assert.ok(homeAfterRoundAdmission.data.notices.some(item => item.title.includes('第 1 轮录取名单公示')), '本轮录取公示应同步进入首页通知公告');
const reportingLedger = await admissionSchoolClient.request('/api/admission/reporting');
const reportingBatch = reportingLedger.data.batches.find(item => item.exam.id === exam.id);
assert.equal(reportingBatch.rows.length, 2, '招生学校报到台账应包含本轮全部正式录取考生');
assert.deepEqual(reportingBatch.rows.map(item => item.noticeNumber), [`${admissionOnlySchool.data.school.code}-${exam.code}-000001`, `${admissionOnlySchool.data.school.code}-${exam.code}-000002`], '通知书编号应使用学校代码、考试代码和学校独立流水号');
const reportingExport = await admissionSchoolClient.request(`/api/admission/reporting/export?examId=${exam.id}`);
assert.equal(reportingExport.response.status, 200, '招生学校应能导出报到状态 Excel');
const reportingWorkbook = new ExcelJS.Workbook(); await reportingWorkbook.xlsx.load(reportingExport.data);
const reportingSheet = reportingWorkbook.getWorksheet('考生报到');
assert.ok(reportingSheet.getRow(2).values.includes('报到状态码*Y/N/P'), '报到 Excel 应明确提供 Y/N/P 状态码列');
assert.equal(reportingSheet.getCell('G3').value, 'P', '新报到批次导出时应默认为待确认状态码 P');
const placementLedgerExport = await admin.request(`/api/admin/admissions/placements/export?examId=${exam.id}&schoolId=${admissionOnlySchool.data.school.id}&status=final&q=${encodeURIComponent(placementCandidates[0].registrationNumber)}`);
assert.equal(placementLedgerExport.response.status, 200, '超级管理员应能按考试、招生学校、状态和搜索词导出录取情况台账');
const placementLedgerWorkbook = new ExcelJS.Workbook(); await placementLedgerWorkbook.xlsx.load(placementLedgerExport.data);
const placementLedgerSheet = placementLedgerWorkbook.getWorksheet('录取情况');
assert.ok(placementLedgerSheet.getRow(2).values.includes('录取状态'), '录取情况台账应包含录取状态列');
assert.equal(placementLedgerSheet.rowCount, 3, '录取台账导出应严格应用当前筛选条件,只保留命中的一名考生');
assert.equal(placementLedgerSheet.getRow(3).values.includes(placementCandidates[0].registrationNumber), true, '筛选后的录取台账应包含搜索命中的报名号');
assert.equal((await classAdmin.request(`/api/admin/admissions/placements/export?examId=${exam.id}`)).response.status, 403, '非超级管理员不得导出全市录取台账');
const finalizedInspector = new DatabaseSync(testDb, { readOnly: true });
const finalizedPlacementRow = finalizedInspector.prepare('SELECT * FROM admission_records WHERE id = ?').get(placementIds[0]);
finalizedInspector.close();
const finalizedPlacement = { id: finalizedPlacementRow.id, kind: finalizedPlacementRow.kind, examId: finalizedPlacementRow.exam_id, userId: finalizedPlacementRow.user_id, schoolId: finalizedPlacementRow.school_id, status: finalizedPlacementRow.status, payload: JSON.parse(finalizedPlacementRow.payload_json), createdAt: finalizedPlacementRow.created_at, updatedAt: finalizedPlacementRow.updated_at };
const noticeCode = admissionNoticeCode('development-document-verification-secret', finalizedPlacement, exam);
const reportingPreview = await admissionSchoolClient.request('/api/admission/reporting/scan-preview', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}` } });
assert.equal(reportingPreview.response.status, 200, '扫描通知书二维码后应先返回考生确认信息');
assert.equal(reportingPreview.data.row.status, 'pending', '扫码预览不得提前修改报到状态');
const scannedReporting = await admissionSchoolClient.request('/api/admission/reporting/scan', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}`, status: 'reported' } });
assert.equal(scannedReporting.response.status, 200, '确认页点击暂存后应能保存扫码报到结果');
assert.equal(scannedReporting.data.row.status, 'reported', '确认页默认选择应能暂存为已报到而不是直接提交');
const secondRow = reportingBatch.rows.find(row => row.noticeNumber !== finalizedPlacement.payload.noticeNumber);
assert.ok(secondRow, '报到测试应找到另一名尚未处理的正式录取考生');
const importReportingFile = Buffer.from(await buildWorkbook('admission_reporting', [{ noticeNumber: secondRow.noticeNumber, candidateNumber: secondRow.candidateNumber, name: secondRow.name, examCode: exam.code, schoolCode: admissionOnlySchool.data.school.code, categoryName: secondRow.categoryName, reportingStatusCode: 'N', reportingNote: '逾期未报到' }]));
const importedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile });
assert.equal(importedReporting.response.status, 200, '招生学校应能导入修改后的报到 Excel 并暂存');
assert.equal(importedReporting.data.count, 1, 'Excel 导入应返回读取行数');
assert.equal(importedReporting.data.changedCount, 1, 'Excel 导入应明确返回实际变化人数');
assert.equal(importedReporting.data.changes[0].toCode, 'N', 'Excel 导入变化摘要应说明修改后的状态码');
const unchangedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile });
assert.equal(unchangedReporting.data.changedCount, 0, '重复导入相同 Excel 时应明确提示没有变化');
assert.equal(unchangedReporting.data.unchangedCount, 1, '重复导入相同 Excel 时应返回未变化行数');
assert.equal((await admissionSchoolClient.request('/api/admission/reporting/submit', { method: 'POST', body: { examId: exam.id } })).response.status, 200, '全部状态确认后招生学校应能提交报到情况');
assert.equal((await admissionSchoolClient.request('/api/admission/reporting/decision', { method: 'POST', body: { examId: exam.id, supplement: true, decisionNote: '一名考生未报到,申请补录缺额' } })).response.status, 200, '招生学校应能根据实时完成率提交补录决定');
const reportingApprovalLedger = await admin.request('/api/admin/admissions');
const pendingReportingApproval = reportingApprovalLedger.data.reportingRequests.find(item => item.examId === exam.id && item.status === 'pending_approval');
assert.ok(pendingReportingApproval, '超级管理员应看到招生学校报到与补录审批待办');
const supplementEnd = new Date(Date.now() + 48 * hour).toISOString();
const approvedReporting = await admin.request(`/api/admin/admission-reporting/${pendingReportingApproval.id}`, { method: 'PATCH', body: { approved: true, approvalNote: '同意按缺额补录', preferenceEnd: supplementEnd } });
assert.equal(approvedReporting.response.status, 200, '超级管理员应能批准补录并自动公开报到情况');
assert.equal(approvedReporting.data.phase, 'supplementary', '全部学校审批完成且存在补录申请时应自动开启下一轮补录');
const reportedCandidateSupplementView = await reportedCandidate.request('/api/candidate/admissions');
const reportedCandidateAdmission = reportedCandidateSupplementView.data.admissions.find(item => item.examId === exam.id);
assert.equal(reportedCandidateAdmission.supplementEligible, false, '已经正式录取并报到的考生不得再次进入补录填报');
assert.match(reportedCandidateAdmission.supplementIneligibilityReason, /已被录取/, '考生页面应明确说明不能重复参加补录的原因');
const repeatedSupplementPreference = await reportedCandidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: admissionOnlySchool.data.school.id, categoryCode: 'general', preferenceType: 'general' }] } });
assert.equal(repeatedSupplementPreference.response.status, 403, '服务端必须拒绝已录取且已报到考生再次提交补录志愿');
const reportingAnnouncements = await anonymous.request('/api/public/announcements');
const reportingPublication = reportingAnnouncements.data.reports.find(item => item.id === pendingReportingApproval.id);
assert.ok(reportingPublication?.title.includes('补录说明'), '批准补录后报到公示标题应自动包含补录说明');
assert.equal(reportingPublication.statistics.reportedCount, 1, '公开报到情况应包含简单统计数据');
const homeAfterReporting = await anonymous.request('/api/public/home');
assert.ok(homeAfterReporting.data.notices.some(item => item.id === `system-reporting-${pendingReportingApproval.id}`), '系统自动生成的报到公示应进入所有用户共用的通知列表');
const admissionAccountLedger = await admin.request('/api/admin/admissions');
const managedAdmissionAccount = admissionAccountLedger.data.schoolAccounts.find(item => item.id === admissionAccount.data.account.id);
assert.equal(managedAdmissionAccount.schoolName, '海州市招生实验学校', '招生学校账户台账应显示绑定学校');
assert.equal(managedAdmissionAccount.active, true, '招生学校账户台账应返回真实启停状态');
const disabledAdmissionAccount = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: false } });
assert.equal(disabledAdmissionAccount.data.account.active, false, '超级管理员应能停用招生学校账户');
assert.equal((await admissionSchoolClient.request('/api/admission/context')).response.status, 401, '停用招生学校账户应立即使既有会话失效');
assert.equal((await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: true } })).data.account.active, true, '超级管理员应能重新启用招生学校账户');
const resetAdmissionPassword = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}/reset-password`, { method: 'POST' });
assert.match(resetAdmissionPassword.data.temporaryPassword, /^Reset-/, '重置招生学校账户密码应返回一次性临时密码');
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: 'Admission123!' } })).response.status, 401, '招生学校账户重置后原密码应失效');
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: resetAdmissionPassword.data.temporaryPassword } })).response.status, 200, '招生学校账户应能使用临时密码重新登录');
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
for (const table of [createdExamPartition.candidates_table, createdExamPartition.admissions_table, createdExamPartition.results_table, createdExamPartition.centers_table]) {
assert.match(table, /^[a-z][a-z0-9_]{0,63}$/);
assert.ok(createdExamInspector.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), `新考试专属表 ${table} 应立即创建`);
}
createdExamInspector.close();
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', '新报名应进入管理员审核队列');
assert.ok(adminRegistration.exam?.name && adminRegistration.schoolName && adminRegistration.gradeName && adminRegistration.className, '报名审核列表应提供考试、学校、年级和班级筛选字段');
assert.deepEqual(adminRegistration.subjects.map(item => item.id), [exam.subjects[0].id, exam.subjects[2].id], '报名审核详情应完整提供考生所报科目');
const candidateReviewList = await admin.request('/api/admin/candidates');
const candidateReviewItem = candidateReviewList.data.candidates.find(item => item.id === profile.id);
assert.ok(candidateReviewItem.registrations.some(item => item.id === registrationId && item.exam?.id === exam.id && item.subjects.length === 2), '考生资料审核应同时提供关联考试和所报科目上下文');
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, '考试报名必须复用考生账户的固定报名号');
assert.equal(approveRegistration.data.registration.paymentStatus, 'unpaid', '报名终审不得代替线下缴费确认');
const superPaymentList = await admin.request('/api/admin/payments');
const schoolPaymentList = await schoolAdmin.request('/api/admin/payments');
const classPaymentList = await classAdmin.request('/api/admin/payments');
assert.ok(superPaymentList.data.registrations.some(item => item.id === registrationId), '超级管理员应能查看全部范围缴费名单');
assert.ok(schoolPaymentList.data.registrations.some(item => item.id === registrationId), '校级管理员应能查看本校缴费名单');
assert.ok(classPaymentList.data.registrations.some(item => item.id === registrationId), '班级负责人应能查看本班缴费名单');
assert.equal(classPaymentList.data.canConfirmPayment, true, '班级负责人应取得缴费确认能力');
assert.equal(superPaymentList.data.canUpdatePayment, true, '超级管理员应能修改全局范围缴费状态');
assert.equal(schoolPaymentList.data.canUpdatePayment, true, '校级管理员应能修改本校范围缴费状态');
assert.ok(superPaymentList.data.registrations.find(item => item.id === registrationId)?.gradeName, '缴费名单应提供年级字段用于筛选');
const superConfirmedPayment = await admin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'paid' } });
assert.equal(superConfirmedPayment.response.status, 200, '超级管理员应能将负责范围考生标记为已缴费');
const schoolRevertedPayment = await schoolAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'unpaid' } });
assert.equal(schoolRevertedPayment.response.status, 200, '校级管理员应能将本校考生改回待缴费');
assert.equal(schoolRevertedPayment.data.payment.paidAt, null, '改回待缴费后应清除原确认时间');
assert.equal(schoolRevertedPayment.data.payment.paidBy, null, '改回待缴费后应清除原确认人');
const confirmedPayment = await classAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'paid' } });
assert.equal(confirmedPayment.response.status, 200, '班级负责人应能在线下收款后确认缴费');
assert.equal(confirmedPayment.data.payment.status, 'paid');
assert.ok(confirmedPayment.data.payment.paidAt && confirmedPayment.data.payment.paidByName, '缴费确认应记录时间和办理人');
assert.equal((await classAdmin2.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' })).response.status, 409, '重复确认缴费应被拒绝');
const paidCandidateRegistration = (await candidate.request('/api/candidate/registrations')).data.registrations.find(item => item.id === registrationId);
assert.equal(paidCandidateRegistration.paymentStatus, 'paid', '班级确认后考生端应同步显示已缴费');
assert.ok(paidCandidateRegistration.paidAt && paidCandidateRegistration.paidByName, '考生应能看到缴费确认记录');
assert.equal(paidCandidateRegistration.amountDue, 45, '应缴金额应按所选科目费用汇总');
for (const [client, label] of [[admin, '超级管理员'], [schoolAdmin, '校级管理员'], [classAdmin, '班级负责人']]) {
const exportPaymentList = await client.request('/api/admin/excel/payments');
assert.equal(exportPaymentList.response.status, 200, `${label}应能导出管理范围内的缴费名单`);
assert.ok(Buffer.isBuffer(exportPaymentList.data) && exportPaymentList.data.length > 1000, `${label}缴费名单应为有效 Excel 文件`);
}
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: '<h2>成绩发布</h2><p>系统测试考试成绩现已发布。<a href="https://files.example.com/results.pdf" target="_blank" onclick="alert(1)">下载说明</a></p><figure class="image"><img src="https://files.example.com/banner.png" alt="成绩发布横幅" onerror="alert(1)"><figcaption>成绩发布说明</figcaption></figure><figure class="table"><table><tbody><tr><th>科目</th><th>状态</th></tr><tr><td>语文</td><td>已发布</td></tr></tbody></table></figure><script>alert(1)</script>',
category: '成绩通知',
pinned: true,
status: 'published'
} });
assert.equal(publishNotice.response.status, 201);
assert.match(publishNotice.data.notice.contentHtml, /<h2>成绩发布<\/h2>/, '通知接口应保留 CKEditor 标题格式');
assert.match(publishNotice.data.notice.contentHtml, /href="https:\/\/files\.example\.com\/results\.pdf"/, '通知接口应保留安全超链接');
assert.match(publishNotice.data.notice.contentHtml, /rel="noopener noreferrer"/, '外部链接应带安全关系属性');
assert.match(publishNotice.data.notice.contentHtml, /<img src="https:\/\/files\.example\.com\/banner\.png" alt="成绩发布横幅" \/>/, '通知接口应保留专用服务器图片链接');
assert.match(publishNotice.data.notice.contentHtml, /<table>.*<th>科目<\/th>.*<td>语文<\/td>.*<\/table>/s, '通知接口应保留表格结构');
assert.doesNotMatch(publishNotice.data.notice.contentHtml, /script|onclick|onerror/i, '通知富文本必须移除脚本和事件属性');
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '<script>alert(1)</script>', status: 'draft' } });
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
const draftNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '待编辑草稿', content: '<p>初始草稿内容</p>', status: 'draft' } });
assert.equal(draftNotice.response.status, 201, '管理员应能保存通知草稿');
const editedDraftNotice = await admin.request(`/api/admin/notices/${draftNotice.data.notice.id}`, { method: 'PATCH', body: { title: '已编辑草稿', content: '<p>修改后的草稿内容</p>', status: 'draft' } });
assert.equal(editedDraftNotice.response.status, 200, '草稿编辑入口应通过原通知修改接口保存');
assert.equal(editedDraftNotice.data.notice.title, '已编辑草稿');
const noticeListAfterEdit = await admin.request('/api/admin/notices');
assert.equal(noticeListAfterEdit.data.notices.filter(item => item.id === draftNotice.data.notice.id).length, 1, '编辑草稿不得重复新建通知');
assert.match(noticeListAfterEdit.data.notices.find(item => item.id === draftNotice.data.notice.id).contentHtml, /修改后的草稿内容/, '草稿正文修改后应正确回填到管理接口');
const refreshedHome = await anonymous.request('/api/public/home');
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
assert.equal(publicRichNotice.contentHtml, publishNotice.data.notice.contentHtml, '公开接口应返回已清洗的富文本正文');
const resultTemplate = await admin.request(`/api/admin/excel/results?template=1&examId=${encodeURIComponent(exam.id)}`);
assert.equal(resultTemplate.response.status, 200, '成绩模板应支持按考试下载完整录分名单');
const resultTemplateWorkbook = new ExcelJS.Workbook();
await resultTemplateWorkbook.xlsx.load(resultTemplate.data);
const resultTemplateSheet = resultTemplateWorkbook.getWorksheet('成绩');
const templateHeaders = resultTemplateSheet.getRow(2).values;
assert.ok(templateHeaders.includes('报名号*') && templateHeaders.includes('准考证号(只读参考)') && templateHeaders.includes('姓名(只读参考)'), '成绩模板应包含报名号、准考证号与姓名列');
const templateCandidateNumberColumn = templateHeaders.indexOf('报名号*');
const templateCandidateNameColumn = templateHeaders.indexOf('姓名(只读参考)');
const templateRows = resultTemplateSheet.getRows(3, Math.max(0, resultTemplateSheet.rowCount - 2)) || [];
const candidateTemplateRow = templateRows.find(row => row.getCell(templateCandidateNumberColumn).text === candidateNumber);
assert.ok(candidateTemplateRow, '成绩模板应预填已通过报名的真实考生,而不是空白示例行');
assert.ok(candidateTemplateRow.getCell(templateCandidateNameColumn).text, '成绩模板应预填考生姓名供核对');
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);
const unselectedResults = await admin.request('/api/admin/results');
assert.deepEqual(unselectedResults.data.results, [], '未选择考试时不得读取成绩数据');
assert.deepEqual(unselectedResults.data.registrations, [], '未选择考试时不得读取录分名单');
assert.ok(!(await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).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?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
const bulkDraftResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: false, rows: [{ registrationId, score: 125.5 }] } });
assert.equal(bulkDraftResult.response.status, 200, '名单式录分应支持按科目批量暂存');
assert.equal(bulkDraftResult.data.published, false);
const publishResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: true, rows: [{ registrationId, score: 126 }] } });
assert.equal(publishResult.response.status, 200, '名单式录分应支持按科目批量发布');
assert.equal(publishResult.data.published, true);
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 adminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0');
const featureScoreUpdate = await admin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 87.5 }] } });
assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能按考试名单批量登记特征分');
assert.equal(featureScoreUpdate.data.count, 1);
assert.equal((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.registrations.find(item => item.id === registrationId).featureScore, 87.5, '批量保存后应立即回填特征分');
assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分');
assert.equal((await classAdmin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 10 }] } })).response.status, 403, '班级管理员不得批量登记特征分');
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });
assert.equal(cacheRefresh.response.status, 200, '超级管理员应能调用成绩缓存刷新接口');
assert.equal(cacheRefresh.data.refreshed, false, 'Redis 未配置时刷新接口应安全降级为数据库直读');
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.match(resultSummary.verificationQr, /^data:image\/png;base64,/, '成绩单防伪信息应包含可直接扫描的二维码');
assert.equal(resultSummary.featureScore, 87.5, '考生端整场成绩应单独显示特征分');
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+'), '单科等级应按同场同科排名计算');
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'filling', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能开放志愿填报并限制提交次数');
const unfilledPreferenceLedger = await admin.request('/api/admin/admissions');
const unfilledPreferenceRow = unfilledPreferenceLedger.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
assert.equal(unfilledPreferenceRow.status, 'unfilled', '实时志愿台账应包含尚未填报的合格考生,而不只是已提交志愿');
const candidateAdmissions = await candidate.request('/api/candidate/admissions');
const candidateAdmission = candidateAdmissions.data.admissions.find(item => item.examId === exam.id);
assert.equal(candidateAdmission.indicatorQualification.payload.eligible, true, '考生页面应显示生源校确认的指标分配资格');
assert.equal(candidateAdmission.maxSubmissions, 1, '考生页面应显示管理员设置的提交次数上限');
const firstPreference = await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [
{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'indicator' },
{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }
] } });
assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿');
assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定');
const lockedPreferenceView = await candidate.request('/api/candidate/admissions');
const lockedChoice = lockedPreferenceView.data.admissions.find(item => item.examId === exam.id).preference.payload.choices[0];
assert.equal(lockedChoice.schoolName, '海州市招生实验学校', '考生志愿锁定后应稳定返回学校名称,而不是只返回代码');
assert.equal(lockedChoice.categoryName, '普通生', '考生志愿锁定后应稳定返回招生类别名称');
const adminPreferenceView = await admin.request('/api/admin/admissions');
const adminLockedChoice = adminPreferenceView.data.preferences.find(item => item.id === firstPreference.data.preference.id).choices[0];
assert.equal(adminLockedChoice.schoolName, '海州市招生实验学校', '管理员志愿台账应显示完整学校名称');
assert.equal(adminLockedChoice.categoryName, '普通生', '管理员志愿台账应显示招生类别名称而不是类别代码');
const lockedSnapshot = adminPreferenceView.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
assert.equal(lockedSnapshot.status, 'locked', '考生提交达到次数上限后,管理员实时台账应同步显示已锁定');
const preferenceLedgerExport = await admin.request(`/api/admin/admissions/preferences/export?examId=${exam.id}&round=${lockedSnapshot.round}&status=locked&q=${encodeURIComponent(candidateNumber)}`);
assert.equal(preferenceLedgerExport.response.status, 200, '超级管理员应能在填报期间按当前搜索和筛选条件导出志愿台账');
const preferenceLedgerWorkbook = new ExcelJS.Workbook(); await preferenceLedgerWorkbook.xlsx.load(preferenceLedgerExport.data);
const preferenceLedgerSheet = preferenceLedgerWorkbook.getWorksheet('志愿填报情况');
assert.ok(preferenceLedgerSheet.getRow(2).values.includes('填报状态'), '志愿台账应包含填报状态列');
assert.equal(preferenceLedgerSheet.rowCount, 4, '一名考生的两个志愿应导出为两条明细,并保留两行表头');
assert.equal(preferenceLedgerSheet.getRow(3).values.includes('海州市招生实验学校'), true, '志愿台账应导出学校名称而不是只导出代码');
assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改');
const classResults = await classAdmin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
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 livePartitionInspector = new DatabaseSync(testDb, { readOnly: true });
const liveExamPartition = livePartitionInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
const partitionCandidate = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.candidates_table}" WHERE registration_id = ?`).get(registrationId);
const partitionAdmissions = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.admissions_table}" WHERE registration_id = ? ORDER BY subject_id`).all(registrationId);
const partitionResults = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.results_table}" WHERE registration_id = ? ORDER BY subject_id`).all(registrationId);
const partitionCenters = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.centers_table}"`).all();
assert.equal(partitionCandidate.candidate_number, candidateNumber, '考试考生专属表应保存该场报名考生');
assert.equal(partitionCandidate.payment_status, 'paid', '考试考生专属表应同步报名与缴费状态');
assert.equal(partitionAdmissions.length, 2, '考试准考证专属表应按报考科目保存考号和座位');
assert.ok(partitionAdmissions.every(item => item.admission_number === generatedCard.number && item.seat), '考试准考证专属表应同步准考证号、考场和座位');
assert.equal(partitionResults.length, 2, '考试成绩专属表应只保存该场考试成绩');
assert.ok(partitionResults.every(item => item.published === 1), '考试成绩专属表应同步发布状态');
assert.ok(partitionCenters.some(item => item.center_code === generatedCard.centerCode), '考试考点专属表应保存本场实际使用的考点');
const schoolStudentPartition = livePartitionInspector.prepare('SELECT students_table FROM school_student_partitions WHERE school_id = ?').get('school_hz1');
const partitionStudent = livePartitionInspector.prepare(`SELECT * FROM "${schoolStudentPartition.students_table}" WHERE candidate_number = ?`).get(candidateNumber);
assert.equal(partitionStudent.candidate_name, '测试考生新名', '学校学生专属表应保存且同步本校学生资料');
livePartitionInspector.close();
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?examId=${encodeURIComponent(exam.id)}`);
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/, '添加流程步骤事件必须取得视图模块导出的编辑器,避免未定义错误');
assert.match(appSource, /createAdminViews, numberSegmentMeta/, '保存报名号规则必须导入字段元数据,避免提交时出现未定义错误');
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('✓ 成绩录入、发布与考生查询');
console.log('✓ 成绩复议、班级审批、范围匹配与多人均分');
} finally {
if (server.exitCode == null && server.signalCode == null) {
const serverExit = new Promise(resolveWait => server.once('exit', resolveWait));
server.kill('SIGTERM');
await serverExit;
}
await rm(testDb, { force: true });
await rm(`${testDb}-shm`, { force: true });
await rm(`${testDb}-wal`, { force: true });
}