Separate base database initialization from test data seeding

This commit is contained in:
2026-07-20 19:49:29 +08:00 Unverified
parent 5b86cdaec2
commit b8f2799ed6
11 changed files with 345 additions and 43 deletions
+40 -1
View File
@@ -1,9 +1,12 @@
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 assert from 'node:assert/strict';
import ExcelJS from 'exceljs';
import { relationalTables } from '../database.mjs';
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 { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
@@ -36,10 +39,31 @@ assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) awai
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| existingSchemaVersion !== 15\)/, 'MySQL 应重建未完成或过期的开发结构');
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
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, '正常首次建库不得预置报名');
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: () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword })
});
await seededTestDatabase.close();
const server = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb },
@@ -127,6 +151,15 @@ try {
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 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 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;
inspector.close();
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
@@ -135,6 +168,12 @@ try {
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
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)), '科目应保存独立及格线计算方式和规则数值');