Implement application updates and refinements
This commit is contained in:
+58
-5
@@ -2,7 +2,9 @@ import { spawn } from 'node:child_process';
|
||||
import { readFile, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { relationalTables } from '../database.mjs';
|
||||
import { buildWorkbook } from '../excel.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const port = 4182;
|
||||
@@ -36,15 +38,16 @@ 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 ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
||||
body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body
|
||||
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() : await response.text();
|
||||
const data = type.includes('application/json') ? await response.json() : type.includes('spreadsheetml') ? Buffer.from(await response.arrayBuffer()) : await response.text();
|
||||
return { response, data };
|
||||
}
|
||||
};
|
||||
@@ -143,7 +146,7 @@ try {
|
||||
const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: {
|
||||
code: 'HZ01-EAST', name: '海州市第一中学东区考点', address: '海州市测试路 8 号', contact: '0518-12345678',
|
||||
managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案',
|
||||
rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }]
|
||||
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', '校级管理员新增考点必须自动归属本校');
|
||||
@@ -158,7 +161,7 @@ try {
|
||||
const centerUpdate = await schoolAdmin.request(`/api/admin/centers/${createdCenter.id}`, { method: 'PATCH', body: {
|
||||
...createdCenter, managerName: '变更后负责人', rooms: [
|
||||
...createdCenter.rooms,
|
||||
{ code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatStart: 31, seatEnd: 55, roomType: 'accessible', status: 'active', notes: '无障碍通道' }
|
||||
{ code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatPlan: '无障碍座位现场标记', roomType: 'accessible', status: 'active', notes: '无障碍通道' }
|
||||
]
|
||||
} });
|
||||
assert.equal(centerUpdate.response.status, 202, '修改考点和考场也必须提交审批');
|
||||
@@ -170,6 +173,55 @@ try {
|
||||
assert.equal(changedCenter.rooms.length, 2, '审批通过后考场明细应按申请快照整体替换');
|
||||
assert.equal((await classAdmin.request('/api/admin/centers')).response.status, 403, '班级管理员不得读取或维护考点');
|
||||
|
||||
const organization = await schoolAdmin.request('/api/admin/school-organization');
|
||||
assert.equal(organization.response.status, 200, '校级管理员应有本校组织管理入口');
|
||||
assert.ok(organization.data.classes.every(item => item.schoolId === 'school_hz1'), '组织页只能返回本校班级');
|
||||
const createClass = await schoolAdmin.request('/api/admin/classes', { method: 'POST', body: { grade: '高二', name: '高二(9)班', active: true } });
|
||||
assert.equal(createClass.response.status, 201, '校级管理员应能新增本校班级');
|
||||
const newClassId = createClass.data.schoolClass.id;
|
||||
const createClassAdmin = await schoolAdmin.request('/api/admin/admins', { method: 'POST', body: { displayName: '批量测试班管', username: 'class_excel_test', password: 'ClassExcel123!', classId: newClassId } });
|
||||
assert.equal(createClassAdmin.response.status, 201, '校级管理员应能创建并绑定本校班级管理员');
|
||||
assert.equal(createClassAdmin.data.admin.adminLevel, 'class');
|
||||
assert.equal(createClassAdmin.data.admin.schoolId, 'school_hz1');
|
||||
assert.equal((await schoolAdmin.request(`/api/admin/admins/${createClassAdmin.data.admin.id}`, { method: 'PATCH', body: { classId: 'class_hz1_301', active: false } })).response.status, 200, '校级管理员应能调整或停用本校班级管理员');
|
||||
assert.equal((await admin.request('/api/admin/classes', { method: 'POST', body: { grade: '越权', name: '越权班' } })).response.status, 403, '超级管理员不应代替学校维护班级');
|
||||
|
||||
const classTemplate = await schoolAdmin.request('/api/admin/excel/classes?template=1');
|
||||
assert.equal(classTemplate.response.status, 200, '班级 Excel 模板应可下载');
|
||||
assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/);
|
||||
const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data);
|
||||
assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头');
|
||||
const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }]));
|
||||
const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile });
|
||||
assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级');
|
||||
assert.equal(classImport.data.count, 1);
|
||||
const quotaFile = Buffer.from(await buildWorkbook('account_quotas', [{ className: '高三(1)班', count: 12 }, { className: '高三(2)班', count: 8 }]));
|
||||
const quotaImport = await schoolAdmin.request('/api/admin/excel/account_quotas', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: quotaFile });
|
||||
assert.deepEqual(quotaImport.data.quotas.map(item => item.count), [12, 8], '报名号配额 Excel 应解析并回填各班数量');
|
||||
const centerTemplate = await schoolAdmin.request('/api/admin/excel/centers?template=1');
|
||||
const centerWorkbook = new ExcelJS.Workbook(); await centerWorkbook.xlsx.load(centerTemplate.data);
|
||||
const centerHeaders = centerWorkbook.worksheets[0].getRow(2).values.map(String);
|
||||
assert.ok(centerHeaders.includes('座位编排说明'), '考场 Excel 应使用座位编排说明');
|
||||
assert.ok(!centerHeaders.some(item => item.includes('座位起号') || item.includes('座位止号')), '考场 Excel 不应再出现座位起止号');
|
||||
assert.equal(centerWorkbook.worksheets[0].getCell('M3').value, '001', '考场代码应保留前导零');
|
||||
assert.equal(centerWorkbook.worksheets[0].getCell('M3').numFmt, '@', '标识号列应固定为文本格式');
|
||||
const classAdminImportFile = Buffer.from(await buildWorkbook('class_admins', [{ schoolCode: 'HZ01', className: '高一(8)班', displayName: 'Excel 班管', username: 'excel_class_admin', initialPassword: 'ExcelClass123!', status: '启用' }]));
|
||||
const classAdminImport = await schoolAdmin.request('/api/admin/excel/class_admins', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classAdminImportFile });
|
||||
assert.equal(classAdminImport.data.count, 1, '班级管理员应支持 Excel 批量导入');
|
||||
const centerImportFile = Buffer.from(await buildWorkbook('centers', [{
|
||||
schoolCode: 'HZ01', centerCode: 'HZ01-XLSX', centerName: 'Excel 导入考点', address: '海州市表格路 1 号', managerName: '表格负责人', managerPhone: '13800138001', contact: '0518-88000000', emergencyPhone: '0518-120', gateOpenTime: '07:10', transport: '北门入场', centerStatus: '启用', centerNotes: 'Excel 自动化测试',
|
||||
roomCode: 'X001', roomName: 'Excel 第 001 考场', building: '综合楼', floor: '2 层', capacity: 32, seatPlan: '按现场桌贴编排', roomType: '标准考场', roomStatus: '启用', roomNotes: ''
|
||||
}]));
|
||||
const centerImport = await schoolAdmin.request('/api/admin/excel/centers', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: centerImportFile });
|
||||
assert.equal(centerImport.response.status, 200, '考点考场 Excel 导入应创建审批申请');
|
||||
assert.equal(centerImport.data.count, 1);
|
||||
const excelPendingCenter = (await schoolAdmin.request('/api/admin/centers')).data.changeRequests.find(item => item.code === 'HZ01-XLSX');
|
||||
assert.ok(excelPendingCenter && excelPendingCenter.status === 'pending', 'Excel 导入的考点必须进入审批而非直接生效');
|
||||
assert.equal(excelPendingCenter.rooms[0].seatPlan, '按现场桌贴编排');
|
||||
assert.equal((await schoolAdmin.request('/api/admin/excel/candidates')).response.status, 200, '考生资料应支持按学校范围导出 Excel');
|
||||
assert.equal((await schoolAdmin.request('/api/admin/excel/results')).response.status, 200, '成绩台账应支持按学校范围导出 Excel');
|
||||
assert.equal((await classAdmin.request('/api/admin/excel/centers')).response.status, 403, '班级管理员不得通过 Excel 接口越权导出考场');
|
||||
|
||||
const numberRules = await admin.request('/api/admin/number-rules');
|
||||
assert.ok(numberRules.data.activeRule.segments.some(item => item.type === 'sequence'), '系统应提供启用的报名号规则');
|
||||
const saveNumberRule = await admin.request('/api/admin/number-rules', { method: 'POST', body: {
|
||||
@@ -359,6 +411,7 @@ try {
|
||||
console.log('✓ 审批流程设计、同级转交与超级管理员监督退回');
|
||||
console.log('✓ 校级按班级批量申领、终审原子建号与结果返回');
|
||||
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
|
||||
console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出');
|
||||
console.log('✓ 成绩录入、发布与考生查询');
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
|
||||
Reference in New Issue
Block a user