项目迁移
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { admissionCutoffRows, admissionPlanProgress, admissionRoundPublications, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus, supplementarySchoolIds } from '../src/services/volunteer-admission.mjs';
|
||||
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../src/services/system-notifications.mjs';
|
||||
|
||||
const now = '2026-07-21T08:00:00.000Z';
|
||||
let sequence = 0;
|
||||
const db = {
|
||||
users: [
|
||||
{ id: 'u-high', role: 'candidate', active: true, candidateNumber: '20260001', displayName: '高分考生' },
|
||||
{ id: 'u-low', role: 'candidate', active: true, candidateNumber: '20260002', displayName: '次高考生' },
|
||||
{ id: 'u-sport', role: 'candidate', active: true, candidateNumber: '20260003', displayName: '特长考生' }
|
||||
],
|
||||
candidateProfiles: [
|
||||
{ userId: 'u-high', name: '高分考生', schoolId: 'source-a', profileCompleted: true, idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
|
||||
{ userId: 'u-low', name: '次高考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
|
||||
{ userId: 'u-sport', name: '特长考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] }
|
||||
],
|
||||
schools: [
|
||||
{ id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' },
|
||||
{ id: 'target-a', name: '第一中学' }, { id: 'target-b', name: '第二中学' }
|
||||
],
|
||||
registrations: [
|
||||
{ id: 'r-high', examId: 'exam', userId: 'u-high', status: 'approved', subjectIds: ['cn', 'math'] },
|
||||
{ id: 'r-low', examId: 'exam', userId: 'u-low', status: 'approved', subjectIds: ['cn', 'math'] },
|
||||
{ id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 88.5 }
|
||||
],
|
||||
results: [
|
||||
{ registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true },
|
||||
{ registrationId: 'r-low', subjectId: 'cn', score: 118, published: true }, { registrationId: 'r-low', subjectId: 'math', score: 126, published: true },
|
||||
{ registrationId: 'r-sport', subjectId: 'cn', score: 105, published: true }, { registrationId: 'r-sport', subjectId: 'math', score: 110, published: true }
|
||||
],
|
||||
admissionRecords: [
|
||||
{ id: 'plan-a', kind: 'plan', examId: 'exam', schoolId: 'target-a', status: 'approved', payload: { categories: [{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }] } },
|
||||
{ id: 'plan-b', kind: 'plan', examId: 'exam', schoolId: 'target-b', status: 'approved', payload: { categories: [
|
||||
{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] },
|
||||
{ code: 'sport', name: '田径特长生', quota: 1, specialtyCategory: 'sports', specialtyType: 'track_field', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] }
|
||||
] } },
|
||||
{ id: 'qual-low', kind: 'indicator_qualification', examId: 'exam', userId: 'u-low', schoolId: 'source-b', status: 'confirmed', payload: { eligible: false } },
|
||||
{ id: 'qual-sport', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
|
||||
{ id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
|
||||
{ id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
|
||||
{ id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
|
||||
]
|
||||
};
|
||||
|
||||
const setting = { examId: 'exam', payload: { round: 1 } };
|
||||
const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
|
||||
assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和');
|
||||
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { code: 'general' }), 215, '普通招生类别不得加入特征分');
|
||||
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { specialtyCategory: 'sports', specialtyType: 'track_field' }), 303.5, '特长生招生类别应使用文化课总分加特征分');
|
||||
assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档');
|
||||
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
|
||||
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.culturalScore, 215, '特长生投档材料应保留文化课原始总分');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.totalScore, 303.5, '特长生类别投档总分应加入特征分');
|
||||
assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类');
|
||||
assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划');
|
||||
|
||||
const specialtyPriorityDb = structuredClone(db);
|
||||
specialtyPriorityDb.users.push({ id: 'u-sport-rival', role: 'candidate', active: true, candidateNumber: '20260004', displayName: '特长竞争考生' });
|
||||
specialtyPriorityDb.candidateProfiles.push({ userId: 'u-sport-rival', name: '特长竞争考生', schoolId: 'source-b', profileCompleted: true, specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] });
|
||||
specialtyPriorityDb.registrations.push({ id: 'r-sport-rival', examId: 'exam', userId: 'u-sport-rival', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 0 });
|
||||
specialtyPriorityDb.results.push({ registrationId: 'r-sport-rival', subjectId: 'cn', score: 125, published: true }, { registrationId: 'r-sport-rival', subjectId: 'math', score: 125, published: true });
|
||||
specialtyPriorityDb.admissionRecords.push(
|
||||
{ id: 'qual-sport-rival', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport-rival', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
|
||||
{ id: 'pref-sport-rival', kind: 'preference', examId: 'exam', userId: 'u-sport-rival', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
|
||||
);
|
||||
const specialtyPriorityPlacements = buildVolunteerPlacements(specialtyPriorityDb, setting, { uid: prefix => `${prefix}-priority-${++sequence}`, nowIso: () => now });
|
||||
assert.equal(specialtyPriorityPlacements.find(item => item.payload.categoryCode === 'sport').userId, 'u-sport', '特长类别应按文化课加特征分排序,而不是只按文化课排序');
|
||||
|
||||
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
|
||||
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
|
||||
assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计');
|
||||
assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计');
|
||||
|
||||
const supplementDb = structuredClone(db);
|
||||
const supplementSetting = { id: 'setting-supplement', kind: 'setting', examId: 'exam', status: 'supplementary', payload: { round: 2 } };
|
||||
supplementDb.admissionRecords.push(
|
||||
supplementSetting,
|
||||
{ id: 'report-a', kind: 'notification', examId: 'exam', schoolId: 'target-a', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } },
|
||||
{ id: 'report-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } }
|
||||
);
|
||||
for (const placement of supplementDb.admissionRecords.filter(item => item.kind === 'placement' && item.payload?.categoryCode === 'general')) placement.status = 'forfeited';
|
||||
for (const [suffix, schoolId, score] of [['a', 'target-a', 230], ['b', 'target-b', 225]]) {
|
||||
supplementDb.users.push({ id: `u-supp-${suffix}`, role: 'candidate', active: true, candidateNumber: `2026010${suffix === 'a' ? 1 : 2}`, displayName: `补录考生${suffix.toUpperCase()}` });
|
||||
supplementDb.candidateProfiles.push({ userId: `u-supp-${suffix}`, name: `补录考生${suffix.toUpperCase()}`, schoolId: 'source-a', profileCompleted: true, specialtyTypes: [] });
|
||||
supplementDb.registrations.push({ id: `r-supp-${suffix}`, examId: 'exam', userId: `u-supp-${suffix}`, status: 'approved', subjectIds: ['cn'] });
|
||||
supplementDb.results.push({ registrationId: `r-supp-${suffix}`, subjectId: 'cn', score, published: true });
|
||||
supplementDb.admissionRecords.push({ id: `pref-supp-${suffix}`, kind: 'preference', examId: 'exam', userId: `u-supp-${suffix}`, status: 'submitted', payload: { round: 2, choices: [{ schoolId, categoryCode: 'general', preferenceType: 'general' }] } });
|
||||
}
|
||||
assert.deepEqual([...supplementarySchoolIds(supplementDb, supplementSetting)].sort(), ['target-a', 'target-b'], '同轮多所学校获批补录时应完整保留学校集合');
|
||||
const supplementPlacements = buildVolunteerPlacements(supplementDb, supplementSetting, { uid: prefix => `${prefix}-supp-${++sequence}`, nowIso: () => now });
|
||||
assert.deepEqual(supplementPlacements.map(item => item.schoolId).sort(), ['target-a', 'target-b'], '放弃考生不得继续占用缺额,两所获批学校都应进入补录投档');
|
||||
const publicRows = publicAdmissionRows(db, 'exam');
|
||||
const highPublicRow = publicRows.find(item => item.registrationNumber === '20260001');
|
||||
assert.equal(highPublicRow.registrationNumber, '20260001', '公示必须公开报名号');
|
||||
assert.equal(highPublicRow.name, '高分考生', '公示必须公开姓名');
|
||||
assert.equal(highPublicRow.totalScore, 250, '普通类别公示总成绩不得加入特征分');
|
||||
assert.equal(highPublicRow.admittedSchool, '第二中学', '公示必须公开录取学校');
|
||||
assert.ok(highPublicRow.idNumberMasked.includes('*') && !highPublicRow.idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
|
||||
assert.equal(publicRows.find(item => item.registrationNumber === '20260003').totalScore, 303.5, '特长生类别公示总成绩应包含特征分');
|
||||
const cutoffs = admissionCutoffRows(db, 'exam');
|
||||
assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
|
||||
const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
|
||||
assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件');
|
||||
assert.equal(qualificationStatus.rows.find(item => item.userId === 'u-sport').specialtyLabel, '体育·田径', '资格公示应包含对应特长类型');
|
||||
|
||||
const documentDb = structuredClone(db);
|
||||
documentDb.exams = [{ id: 'exam', code: 'EX-2026-ZK', name: '中考' }];
|
||||
documentDb.schools.find(item => item.id === 'target-b').code = 'AD02';
|
||||
const targetPlacements = documentDb.admissionRecords.filter(item => item.kind === 'placement' && item.schoolId === 'target-b');
|
||||
const numbered = assignAdmissionNoticeNumbers(documentDb, targetPlacements);
|
||||
assert.deepEqual(numbered.map(item => item.payload.noticeNumber), ['AD02-EX-2026-ZK-000001', 'AD02-EX-2026-ZK-000002'], '通知书编号应按学校与考试独立生成连续流水号');
|
||||
const reportingPlan = documentDb.admissionRecords.find(item => item.id === 'plan-b');
|
||||
documentDb.admissionRecords = documentDb.admissionRecords.map(item => numbered.find(numberedItem => numberedItem.id === item.id) || item);
|
||||
const legacyRoundDb = structuredClone(documentDb);
|
||||
legacyRoundDb.admissionRecords.push({ id: 'setting-exam', kind: 'setting', examId: 'exam', status: 'reporting', updatedAt: now, payload: { enabled: true, autoPublish: true, round: 1, roundPublishedAt: now } });
|
||||
const legacyRoundPublication = admissionRoundPublications(legacyRoundDb).find(item => item.examId === 'exam' && item.round === 1);
|
||||
assert.ok(legacyRoundPublication?.virtual, '历史报到中数据缺少轮次公示记录时应自动兼容回显');
|
||||
assert.equal(legacyRoundPublication.rows.length, publicAdmissionRows(legacyRoundDb, 'exam', { round: 1 }).length, '历史轮次公示应恢复该轮全部正式录取名单');
|
||||
documentDb.admissionRecords.push({ id: 'reporting-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'draft', payload: { type: 'admission_reporting', round: 1, rows: [{ placementId: numbered[0].id, status: 'reported' }, { placementId: numbered[1].id, status: 'not_reported' }] } });
|
||||
const progress = admissionPlanProgress(documentDb, reportingPlan);
|
||||
assert.equal(progress.totalQuota, 2, '计划完成率分母应来自学校审核通过的计划人数');
|
||||
assert.equal(progress.reportedCount, 1, '实际报到人数应来自学校报到暂存台账');
|
||||
assert.equal(progress.reportingRate, 50, '实际报到完成率应实时按计划人数计算');
|
||||
documentDb.admissionRecords.at(-1).status = 'approved';
|
||||
documentDb.admissionRecords.at(-1).payload.supplementDecision = 'no_supplement';
|
||||
documentDb.admissionRecords.at(-1).payload.decisionNote = '学校研究决定不进行补录。';
|
||||
documentDb.admissionRecords.at(-1).payload.statistics = progress;
|
||||
const reportingNotice = systemNotificationItems(documentDb).find(item => item.sourceType === 'reporting');
|
||||
assert.ok(reportingNotice.title.includes('报到情况公示') && !reportingNotice.title.includes('补录'), '计划完成或决定不补录时公告标题不应出现“补录”');
|
||||
|
||||
console.log('志愿投档、指标名额与脱敏公示测试通过');
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { api } from '../src/client/api.mjs';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
let release;
|
||||
globalThis.fetch = async () => {
|
||||
calls += 1;
|
||||
await new Promise(resolve => { release = resolve; });
|
||||
return new Response(JSON.stringify({ ok: true, calls }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
|
||||
try {
|
||||
const first = api('/api/large-ledger');
|
||||
const second = api('/api/large-ledger');
|
||||
await Promise.resolve();
|
||||
assert.equal(calls, 1, '并发的相同 GET 请求应只发送一次');
|
||||
release();
|
||||
assert.deepEqual(await first, { ok: true, calls: 1 });
|
||||
assert.deepEqual(await second, { ok: true, calls: 1 });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
console.log('API read deduplication tests passed');
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createAuthStateStore } from '../src/security/auth-state.mjs';
|
||||
|
||||
class FakeRedisClient extends EventEmitter {
|
||||
constructor({ connectError = null } = {}) {
|
||||
super();
|
||||
this.connectError = connectError;
|
||||
this.isOpen = false;
|
||||
this.values = new Map();
|
||||
this.sets = new Map();
|
||||
this.hashes = new Map();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connectError) throw this.connectError;
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
async set(key, value) {
|
||||
this.values.set(key, String(value));
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async del(key) {
|
||||
const deleted = Number(this.values.delete(key)) + Number(this.sets.delete(key)) + Number(this.hashes.delete(key));
|
||||
return deleted ? 1 : 0;
|
||||
}
|
||||
|
||||
async sAdd(key, value) {
|
||||
if (!this.sets.has(key)) this.sets.set(key, new Set());
|
||||
const before = this.sets.get(key).size;
|
||||
this.sets.get(key).add(value);
|
||||
return this.sets.get(key).size - before;
|
||||
}
|
||||
|
||||
async sRem(key, value) {
|
||||
return Number(this.sets.get(key)?.delete(value) || false);
|
||||
}
|
||||
|
||||
async sMembers(key) {
|
||||
return [...(this.sets.get(key) || [])];
|
||||
}
|
||||
|
||||
async expire() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async hSet(key, entries) {
|
||||
if (!this.hashes.has(key)) this.hashes.set(key, new Map());
|
||||
for (const [field, value] of Object.entries(entries)) this.hashes.get(key).set(field, String(value));
|
||||
return Object.keys(entries).length;
|
||||
}
|
||||
|
||||
async hGetAll(key) {
|
||||
return Object.fromEntries(this.hashes.get(key) || []);
|
||||
}
|
||||
|
||||
async eval(_script, { keys, arguments: scriptArguments }) {
|
||||
const hash = this.hashes.get(keys[0]);
|
||||
if (!hash) return -1;
|
||||
const attempts = Number(hash.get('attempts') || 0) + 1;
|
||||
hash.set('attempts', String(attempts));
|
||||
if (attempts >= Number(scriptArguments[0])) this.hashes.delete(keys[0]);
|
||||
return attempts;
|
||||
}
|
||||
|
||||
multi() {
|
||||
const operations = [];
|
||||
const transaction = {};
|
||||
for (const method of ['set', 'del', 'sAdd', 'sRem', 'expire', 'hSet']) {
|
||||
transaction[method] = (...args) => {
|
||||
operations.push(() => this[method](...args));
|
||||
return transaction;
|
||||
};
|
||||
}
|
||||
transaction.exec = async () => Promise.all(operations.map(operation => operation()));
|
||||
return transaction;
|
||||
}
|
||||
|
||||
async quit() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
const silentLogger = { error() {} };
|
||||
|
||||
{
|
||||
const state = await createAuthStateStore({ env: {} });
|
||||
assert.equal(state.status, 'disabled');
|
||||
assert.equal(state.backend, 'memory');
|
||||
await state.createSession('session-1', 'user-1');
|
||||
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
|
||||
await state.createLoginChallenge('challenge-1', 'user-1');
|
||||
assert.deepEqual(await state.getLoginChallenge('challenge-1'), { userId: 'user-1', attempts: 0 });
|
||||
await state.createTotpSetup('session-1', 'user-1', 'SECRET');
|
||||
assert.deepEqual(await state.getTotpSetup('session-1'), { userId: 'user-1', secret: 'SECRET' });
|
||||
assert.equal(await state.deleteUserSessions('user-1'), 1);
|
||||
assert.equal(await state.getSession('session-1'), null);
|
||||
await state.close();
|
||||
}
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient();
|
||||
let clientOptions;
|
||||
const state = await createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_PREFIX: 'test:auth' },
|
||||
logger: silentLogger,
|
||||
clientFactory(options) {
|
||||
clientOptions = options;
|
||||
return client;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(state.status, 'ready');
|
||||
assert.equal(state.backend, 'redis');
|
||||
assert.equal(state.database, 1, '普通缓存使用 DB 0 时,认证状态应自动使用 DB 1');
|
||||
assert.equal(clientOptions.database, 1);
|
||||
assert.equal(clientOptions.url, 'redis://cache.example:6379/0');
|
||||
|
||||
await state.createSession('session-1', 'user-1');
|
||||
await state.createSession('session-2', 'user-1');
|
||||
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
|
||||
assert.equal(await state.deleteUserSessions('user-1'), 2);
|
||||
assert.equal(await state.getSession('session-1'), null);
|
||||
assert.equal(await state.getSession('session-2'), null);
|
||||
|
||||
await state.createLoginChallenge('challenge-1', 'user-1');
|
||||
for (let attempts = 1; attempts <= 4; attempts += 1) {
|
||||
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts, exhausted: false });
|
||||
}
|
||||
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts: 5, exhausted: true });
|
||||
assert.equal(await state.getLoginChallenge('challenge-1'), null);
|
||||
|
||||
await state.createTotpSetup('session-3', 'user-1', 'SECRET');
|
||||
assert.deepEqual(await state.getTotpSetup('session-3'), { userId: 'user-1', secret: 'SECRET' });
|
||||
await state.deleteTotpSetup('session-3');
|
||||
assert.equal(await state.getTotpSetup('session-3'), null);
|
||||
await state.close();
|
||||
assert.equal(client.isOpen, false);
|
||||
}
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient();
|
||||
let options;
|
||||
const state = await createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_URL: 'rediss://sessions.example:6380/4' },
|
||||
logger: silentLogger,
|
||||
clientFactory(clientOptions) {
|
||||
options = clientOptions;
|
||||
return client;
|
||||
}
|
||||
});
|
||||
assert.equal(options.url, 'rediss://sessions.example:6380/4');
|
||||
assert.equal(options.database, 4);
|
||||
await state.close();
|
||||
}
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient();
|
||||
let options;
|
||||
const state = await createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache.example:6379/1', REDIS_SESSION_DB: '0' },
|
||||
logger: silentLogger,
|
||||
clientFactory(clientOptions) {
|
||||
options = clientOptions;
|
||||
return client;
|
||||
}
|
||||
});
|
||||
assert.equal(options.database, 0, '应允许显式选择 DB 0,只要普通缓存使用不同的 DB');
|
||||
await state.close();
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://same.example:6379/0', REDIS_SESSION_URL: 'redis://same.example:6379/0' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => new FakeRedisClient()
|
||||
}),
|
||||
/必须使用与普通缓存不同的逻辑数据库/
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache-user@same.example:6379/0', REDIS_SESSION_URL: 'redis://session-user@same.example:6379/0' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => new FakeRedisClient()
|
||||
}),
|
||||
/必须使用与普通缓存不同的逻辑数据库/
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://unavailable.example:6379/0' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => new FakeRedisClient({ connectError: new Error('connection refused') })
|
||||
}),
|
||||
/认证状态存储连接失败/
|
||||
);
|
||||
|
||||
console.log('✓ 认证状态本机回退、独立 Redis DB、会话失效与 TOTP 临时状态');
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRedisCache, withCacheInvalidation } from '../src/cache/redis-cache.mjs';
|
||||
|
||||
class FakeRedisClient extends EventEmitter {
|
||||
constructor({ connectError = null } = {}) {
|
||||
super();
|
||||
this.connectError = connectError;
|
||||
this.isOpen = false;
|
||||
this.isReady = false;
|
||||
this.values = new Map();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connectError) throw this.connectError;
|
||||
this.isOpen = true;
|
||||
this.isReady = true;
|
||||
this.emit('ready');
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
async set(key, value, options = {}) {
|
||||
if (options.NX && this.values.has(key)) return null;
|
||||
this.values.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async incr(key) {
|
||||
const next = Number(this.values.get(key) || 0) + 1;
|
||||
this.values.set(key, String(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
async quit() {
|
||||
this.isReady = false;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.isReady = false;
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
const silentLogger = { warn() {} };
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient();
|
||||
const cache = await createRedisCache({
|
||||
env: { REDIS_URL: 'redis://test', REDIS_CACHE_PREFIX: 'test', REDIS_CACHE_TTL_SECONDS: '30' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => client
|
||||
});
|
||||
let loads = 0;
|
||||
const load = async () => ({ version: ++loads });
|
||||
|
||||
assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 });
|
||||
assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 });
|
||||
assert.equal(loads, 1, '相同缓存键应只读取一次数据源');
|
||||
|
||||
await cache.invalidate('public');
|
||||
assert.deepEqual(await cache.remember('public', 'home', load), { version: 2 });
|
||||
assert.equal(loads, 2, '命名空间失效后应重新读取数据源');
|
||||
|
||||
let resultLoads = 0;
|
||||
const loadResults = async () => ({ version: ++resultLoads });
|
||||
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 1 });
|
||||
|
||||
const database = withCacheInvalidation({
|
||||
client: 'test',
|
||||
async read() { return {}; },
|
||||
async save() { return 'saved'; },
|
||||
async close() {}
|
||||
}, cache, method => method === 'save' ? ['public', 'results'] : ['public']);
|
||||
assert.equal(await database.save(), 'saved');
|
||||
assert.deepEqual(await cache.remember('public', 'home', load), { version: 3 });
|
||||
assert.equal(loads, 3, '数据库写入后应让公开缓存失效');
|
||||
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 2 });
|
||||
assert.equal(resultLoads, 2, '成绩相关写入后应让成绩缓存失效');
|
||||
await cache.invalidate('results');
|
||||
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 3 });
|
||||
assert.equal(resultLoads, 3, '后台手动刷新后应重新生成成绩缓存');
|
||||
|
||||
client.isReady = false;
|
||||
let fallbackLoads = 0;
|
||||
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
|
||||
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
|
||||
assert.equal(fallbackLoads, 1, 'Redis 运行中断开后,相同热点读取应由本机缓存合并');
|
||||
client.isReady = true;
|
||||
await cache.close();
|
||||
}
|
||||
|
||||
{
|
||||
const cache = await createRedisCache({ env: {}, logger: silentLogger });
|
||||
let loads = 0;
|
||||
assert.equal(await cache.remember('public', 'home', async () => ++loads), 1);
|
||||
assert.equal(await cache.remember('public', 'home', async () => ++loads), 1);
|
||||
assert.equal(loads, 1, '未配置 Redis 时应使用有界本机缓存,避免重复回源');
|
||||
await cache.invalidate('public');
|
||||
assert.equal(await cache.remember('public', 'home', async () => ++loads), 2);
|
||||
assert.equal(loads, 2, '本机缓存应在数据库写入后立即失效');
|
||||
assert.equal(cache.status, 'disabled');
|
||||
|
||||
let releaseOld;
|
||||
const oldLoad = cache.remember('public', 'race', () => new Promise(resolve => { releaseOld = () => resolve('old'); }));
|
||||
await cache.invalidate('public');
|
||||
const newLoad = cache.remember('public', 'race', async () => 'new');
|
||||
releaseOld();
|
||||
assert.equal(await oldLoad, 'old');
|
||||
assert.equal(await newLoad, 'new', '失效后不得等待失效前仍在运行的加载');
|
||||
assert.equal(await cache.remember('public', 'race', async () => 'unexpected'), 'new', '旧加载完成后不得覆盖新缓存');
|
||||
}
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient({ connectError: new Error('connection refused') });
|
||||
const cache = await createRedisCache({
|
||||
env: { REDIS_URL: 'redis://unavailable' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => client
|
||||
});
|
||||
assert.equal(cache.status, 'unavailable');
|
||||
assert.equal(await cache.remember('public', 'home', async () => 'database'), 'database');
|
||||
assert.equal(await cache.remember('public', 'home', async () => 'unexpected'), 'database', 'Redis 故障时本机缓存应继续承接重复读取');
|
||||
}
|
||||
|
||||
console.log('Redis 缓存测试通过');
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAdminViews } from '../src/client/admin-views.mjs';
|
||||
import { createAdmissionViews } from '../src/client/admission-views.mjs';
|
||||
import { api } from '../src/client/api.mjs';
|
||||
import { createCandidateViews } from '../src/client/candidate-views.mjs';
|
||||
import { createPublicViews } from '../src/client/public-views.mjs';
|
||||
|
||||
function protectedViewContext() {
|
||||
let loginRequests = 0;
|
||||
const context = {
|
||||
state: { user: null },
|
||||
app: { classList: { add() {}, remove() {} } },
|
||||
requireLogin() { loginRequests += 1; }
|
||||
};
|
||||
return { context, loginRequests: () => loginRequests };
|
||||
}
|
||||
|
||||
for (const createView of [createAdminViews, createCandidateViews, createAdmissionViews]) {
|
||||
const fixture = protectedViewContext();
|
||||
const views = createView(fixture.context);
|
||||
const render = views.renderAdmin || views.renderCandidate || views.renderAdmission;
|
||||
await render('dashboard');
|
||||
assert.equal(fixture.loginRequests(), 1, '未登录访问受保护视图时应交给统一登录处理');
|
||||
}
|
||||
|
||||
{
|
||||
const app = { classList: { remove() {} }, innerHTML: '' };
|
||||
const state = {
|
||||
user: null,
|
||||
authNotice: '登录状态已失效,请重新登录。',
|
||||
publicData: { selfRegistrationEnabled: false }
|
||||
};
|
||||
const { renderAuth } = createPublicViews({
|
||||
state,
|
||||
app,
|
||||
h: value => String(value ?? ''),
|
||||
icons: { arrow: '', menu: '' }
|
||||
});
|
||||
renderAuth('login');
|
||||
assert.match(app.innerHTML, /需要重新登录/);
|
||||
assert.match(app.innerHTML, /登录状态已失效,请重新登录/);
|
||||
}
|
||||
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({ message: '请先登录' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
try {
|
||||
await assert.rejects(() => api('/api/protected'), error => error.status === 401 && error.message === '请先登录');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('客户端登录失效处理测试通过');
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from '../src/security/document-verification.mjs';
|
||||
|
||||
const secret = 'test-document-verification-secret-32-characters';
|
||||
const exam = { id: 'exam_1' };
|
||||
const registration = { id: 'registration_1', userId: 'candidate_1' };
|
||||
const results = [
|
||||
{ subjectId: 'math', score: 118, publishedAt: '2026-07-20T08:00:00.000Z' },
|
||||
{ subjectId: 'chinese', score: 112, publishedAt: '2026-07-20T08:00:00.000Z' }
|
||||
];
|
||||
|
||||
const scoreCode = scoreReportCode(secret, registration, exam, results);
|
||||
const reorderedCode = scoreReportCode(secret, registration, exam, [...results].reverse());
|
||||
const changedScoreCode = scoreReportCode(secret, registration, exam, [{ ...results[0], score: 119 }, results[1]]);
|
||||
|
||||
assert.match(scoreCode, /^SR-[A-F0-9]{24}$/);
|
||||
assert.equal(scoreCode, reorderedCode, '科目返回顺序不应改变同一成绩单的防伪码');
|
||||
assert.notEqual(scoreCode, changedScoreCode, '成绩变化必须使旧防伪码失效');
|
||||
assert.equal(safeCodeEqual(scoreCode, scoreCode.toLowerCase()), true);
|
||||
assert.equal(safeCodeEqual(scoreCode, `${scoreCode}0`), false);
|
||||
|
||||
const placement = {
|
||||
id: 'placement_1', userId: 'candidate_1', schoolId: 'school_1',
|
||||
payload: { categoryCode: 'general', noticeNumber: 'AD01-EX-2026-000001' }, updatedAt: '2026-07-21T08:00:00.000Z'
|
||||
};
|
||||
const noticeCode = admissionNoticeCode(secret, placement, exam);
|
||||
const movedSchoolCode = admissionNoticeCode(secret, { ...placement, schoolId: 'school_2' }, exam);
|
||||
const changedNumberCode = admissionNoticeCode(secret, { ...placement, payload: { ...placement.payload, noticeNumber: 'AD01-EX-2026-000002' } }, exam);
|
||||
|
||||
assert.match(noticeCode, /^AN-[A-F0-9]{24}$/);
|
||||
assert.notEqual(noticeCode, movedSchoolCode, '录取学校变化必须使旧通知书防伪码失效');
|
||||
assert.notEqual(noticeCode, changedNumberCode, '录取通知书编号变化必须使旧防伪码失效');
|
||||
|
||||
assert.throws(
|
||||
() => resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: 'too-short' }),
|
||||
/至少 32 个字符/,
|
||||
'生产环境不得静默使用弱密钥或开发回退值'
|
||||
);
|
||||
assert.equal(
|
||||
resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: secret }),
|
||||
secret
|
||||
);
|
||||
|
||||
console.log('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
|
||||
const state = createSeedDatabase({ nowIso: () => '2026-07-21T00:00:00.000Z', hashPassword: password => `test-${password}` });
|
||||
const mainExam = state.exams.find(exam => exam.id === 'exam_autumn_2026');
|
||||
const mainRegistrations = state.registrations.filter(registration => registration.examId === mainExam.id);
|
||||
const mainRegistrationIds = new Set(mainRegistrations.map(registration => registration.id));
|
||||
const mainResults = state.results.filter(result => mainRegistrationIds.has(result.registrationId));
|
||||
const mainPreferences = state.admissionRecords.filter(record => record.kind === 'preference' && record.examId === mainExam.id && Number(record.payload?.round || 1) === 1);
|
||||
const mainPlans = state.admissionRecords.filter(record => record.kind === 'plan' && record.examId === mainExam.id && record.status === 'approved');
|
||||
const specialtyProfiles = state.candidateProfiles.filter(profile => profile.specialtyCategory && profile.specialtyType);
|
||||
const specialtyUserIds = new Set(specialtyProfiles.map(profile => profile.userId));
|
||||
const profilesByUserId = new Map(state.candidateProfiles.map(profile => [profile.userId, profile]));
|
||||
|
||||
assert.ok(state.exams.length >= 2, '演示数据至少包含两场考试');
|
||||
assert.equal(state.schools.filter(school => school.isSourceSchool).length, 5, '演示数据应包含 5 所生源校');
|
||||
assert.equal(state.schools.filter(school => school.isAdmissionSchool).length, 3, '演示数据应包含 3 所招生校');
|
||||
assert.deepEqual(Object.fromEntries(mainExam.subjects.map(subject => [subject.name, subject.fullScore])), {
|
||||
语文: 120, 数学: 120, 外语: 120, 历史: 75, 政治: 75, 物理: 80, 化学: 70, 实验: 20, 信息技术: 10
|
||||
});
|
||||
assert.equal(mainRegistrations.length, 1200, '主考试应有 1200 名考生');
|
||||
assert.ok(mainRegistrations.every(registration => registration.status === 'approved' && registration.subjectIds.length === 9), '主考试报名应全部审核通过并包含 9 科');
|
||||
assert.equal(mainResults.length, 1200 * 9, '每名主考试考生都应有完整的 9 科成绩');
|
||||
assert.ok(mainResults.every(result => result.published), '主考试成绩应全部发布');
|
||||
assert.equal(mainPreferences.length, 1200, '主考试每名考生都应完成第一轮志愿');
|
||||
assert.ok(mainPreferences.every(record => record.status === 'submitted' && record.payload.submissionCount === 1 && record.payload.choices.length === 3), '第一轮志愿应提交并填满 3 个招生校');
|
||||
assert.equal(mainPlans.length, 3, '三所招生校都应有已审核通过的招生计划');
|
||||
assert.ok(mainPlans.every(plan => plan.payload.categories.find(category => category.code === 'general')?.quota === 350), '每所招生校普通类计划应为 350 人');
|
||||
assert.ok(mainPlans.every(plan => plan.payload.categories.filter(category => category.specialtyCategory).reduce((sum, category) => sum + category.quota, 0) === 2), '每所招生校特长生计划合计应为 2 人');
|
||||
assert.equal(specialtyProfiles.length, 150, '应有 150 名特长生');
|
||||
assert.ok(mainRegistrations.filter(registration => specialtyUserIds.has(registration.userId)).every(registration => registration.featureScore >= 80 && registration.featureScore <= 100), '特长生特征分应分布在 80-100 分');
|
||||
assert.ok(mainPreferences.every(preference => {
|
||||
const profile = profilesByUserId.get(preference.userId);
|
||||
const categories = preference.payload.choices.map(choice => choice.categoryCode);
|
||||
return profile.specialtyCategory ? categories[0] === profile.specialtyCategory && categories.slice(1).every(code => code === 'general') : categories.every(code => code === 'general');
|
||||
}), '特长生第一志愿应匹配本人特长类别,其余志愿及普通考生志愿应填报普通类');
|
||||
assert.deepEqual(new Set(state.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置账号密码应统一为 12345678');
|
||||
|
||||
for (const subject of mainExam.subjects) {
|
||||
const scores = mainResults.filter(result => result.subjectId === subject.id).map(result => result.score);
|
||||
const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
|
||||
const standardDeviation = Math.sqrt(scores.reduce((sum, score) => sum + (score - mean) ** 2, 0) / scores.length);
|
||||
assert.ok(mean > subject.fullScore * 0.66 && mean < subject.fullScore * 0.78, `${subject.name}平均分应符合正态样本预期`);
|
||||
assert.ok(standardDeviation > subject.fullScore * 0.09 && standardDeviation < subject.fullScore * 0.18, `${subject.name}标准差应符合正态样本预期`);
|
||||
assert.ok(scores.every(score => score >= 0 && score <= subject.fullScore), `${subject.name}成绩不得超出满分`);
|
||||
}
|
||||
|
||||
console.log('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过');
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStateCache } from '../src/database/state-cache.mjs';
|
||||
|
||||
let loads = 0;
|
||||
let release;
|
||||
const gate = new Promise(resolve => { release = resolve; });
|
||||
const coalesced = createStateCache({
|
||||
maxAgeMs: 30000,
|
||||
async load() {
|
||||
loads += 1;
|
||||
await gate;
|
||||
return { load: loads };
|
||||
}
|
||||
});
|
||||
const firstPending = coalesced.read();
|
||||
const secondPending = coalesced.read();
|
||||
release();
|
||||
const [first, second] = await Promise.all([firstPending, secondPending]);
|
||||
assert.equal(loads, 1, '并发冷读取应合并为一次全量加载');
|
||||
assert.strictEqual(first, second, '并发读取应共享同一份快照');
|
||||
assert.strictEqual(await coalesced.read(), first, '有效期内应直接复用内存快照');
|
||||
|
||||
coalesced.invalidate();
|
||||
const afterInvalidation = await coalesced.read();
|
||||
assert.equal(loads, 2, '应用写入失效后应重新加载');
|
||||
assert.notStrictEqual(afterInvalidation, first, '失效后不得继续返回旧快照');
|
||||
|
||||
let version = 1;
|
||||
let versionLoads = 0;
|
||||
const versioned = createStateCache({
|
||||
version: () => version,
|
||||
load: () => ({ load: ++versionLoads })
|
||||
});
|
||||
const versionOne = await versioned.read();
|
||||
assert.strictEqual(await versioned.read(), versionOne, '数据库版本未变化时应复用快照');
|
||||
version += 1;
|
||||
const versionTwo = await versioned.read();
|
||||
assert.equal(versionLoads, 2, '外部数据库版本变化后应重新加载');
|
||||
assert.notStrictEqual(versionTwo, versionOne, '外部写入后不得返回旧快照');
|
||||
|
||||
console.log('State cache tests passed');
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user