1160 lines
118 KiB
JavaScript
1160 lines
118 KiB
JavaScript
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 { totpAtStep } from '../src/security/totp.mjs';
|
|
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.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 testDataImportSource = await readFile(resolve(root, 'scripts', 'import-test-data.mjs'), 'utf8');
|
|
assert.match(testDataImportSource, /loadEnvFile\(envPath\)/, '测试数据脚本应读取项目 .env');
|
|
assert.match(testDataImportSource, /options\.has\('--mysql'\).*options\.has\('--sqlite'\)/, '测试数据脚本应支持显式选择 MySQL 或 SQLite');
|
|
assert.doesNotMatch(testDataImportSource, /process\.env\.DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '测试数据脚本不得再强制使用 SQLite');
|
|
assert.match(testDataImportSource, /nonEmpty\.length && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖');
|
|
assert.match(testDataImportSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例数据替换应在受控外键环境中执行');
|
|
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
|
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
|
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
|
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
|
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;
|
|
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能按考试启用志愿功能并设置填报次数');
|
|
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 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.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, '已处理记录不得被重复批量审核');
|
|
|
|
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);
|
|
assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
|
|
const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } });
|
|
assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩');
|
|
assert.equal(stagedCommit.data.count, 1);
|
|
assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
|
|
const 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');
|
|
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')).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.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 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, '达到管理员设置的提交次数后应自动锁定');
|
|
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');
|
|
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');
|
|
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 });
|
|
}
|