forked from wangziqi/gongxue-base
fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import Database from 'better-sqlite3';
|
||||
type SqliteDB = InstanceType<typeof Database>;
|
||||
|
||||
/**
|
||||
* Real SQLite foreign-key constraint tests.
|
||||
*
|
||||
* These tests use the `better-sqlite3` driver directly (in-memory) to verify
|
||||
* that ON DELETE RESTRICT is enforced at the database level, not just in
|
||||
* application-layer guards.
|
||||
*/
|
||||
describe('attendance_sessions FK RESTRICT — real SQLite', () => {
|
||||
let db: SqliteDB;
|
||||
|
||||
function createSchema(): void {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
is_archived INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS class_schedule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER,
|
||||
week_day INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status TEXT DEFAULT 'in_progress',
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
createSchema();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('blocks class deletion when attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('allows class deletion when no attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).not.toThrow();
|
||||
|
||||
const remaining = db.prepare('SELECT COUNT(*) as cnt FROM classes').get() as {
|
||||
cnt: number;
|
||||
};
|
||||
expect(remaining.cnt).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks schedule deletion when attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM class_schedule WHERE id = 1');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('PRAGMA foreign_key_list confirms both FKs are present', () => {
|
||||
// Use raw SQL PRAGMA to avoid better-sqlite3 pragma API quirks
|
||||
const rows = db.prepare("PRAGMA foreign_key_list('attendance_sessions')").all() as Array<{
|
||||
id: number;
|
||||
seq: number;
|
||||
table: string;
|
||||
from: string;
|
||||
to: string;
|
||||
on_update: string;
|
||||
on_delete: string;
|
||||
match: string;
|
||||
}>;
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const scheduleFk = rows.find((fk) => fk.from === 'schedule_id');
|
||||
expect(scheduleFk).toBeDefined();
|
||||
expect(scheduleFk!.table).toBe('class_schedule');
|
||||
expect(scheduleFk!.on_delete).toBe('RESTRICT');
|
||||
|
||||
const classFk = rows.find((fk) => fk.from === 'class_id');
|
||||
expect(classFk).toBeDefined();
|
||||
expect(classFk!.table).toBe('classes');
|
||||
expect(classFk!.on_delete).toBe('RESTRICT');
|
||||
});
|
||||
|
||||
it('FK pragma respects ON DELETE RESTRICT for class_id — data survives failed delete', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
// Verify the session exists
|
||||
const session = db
|
||||
.prepare('SELECT * FROM attendance_sessions WHERE class_id = 1')
|
||||
.get() as Record<string, unknown>;
|
||||
expect(session).toBeDefined();
|
||||
|
||||
// Delete should fail
|
||||
expect(() => db.exec('DELETE FROM classes WHERE id = 1')).toThrow();
|
||||
|
||||
// Session should still exist after failed delete
|
||||
const after = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE class_id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(after.cnt).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test: simulate the protectAttendanceHistory SQLite migration.
|
||||
*
|
||||
* Creates tables WITHOUT foreign keys (pre-migration state), inserts parent
|
||||
* session and child attendance_record, runs the table-rebuild migration
|
||||
* (PRAGMA foreign_keys=OFF, rebuild both tables, PRAGMA foreign_keys=ON,
|
||||
* foreign_key_check), then verifies:
|
||||
* - attendance_record.attendance_session_id is preserved
|
||||
* - RESTRICT still blocks class/schedule deletion
|
||||
*/
|
||||
describe('protectAttendanceHistory SQLite migration — integration', () => {
|
||||
let db: SqliteDB;
|
||||
|
||||
function createPreMigrationSchema(): void {
|
||||
// Schema WITHOUT foreign keys on attendance_sessions (pre-migration)
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
is_archived INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS class_schedule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER,
|
||||
week_day INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
// attendance_sessions WITHOUT foreign keys
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status TEXT DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
// Legacy columns came first; course-attendance columns were appended later.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function runMigration(): void {
|
||||
// Step 1: PRAGMA foreign_keys = OFF outside transaction
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
// Rebuild attendance_sessions with FKs
|
||||
db.exec(`
|
||||
CREATE TABLE attendance_sessions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
db.exec(
|
||||
'INSERT INTO attendance_sessions_new SELECT * FROM attendance_sessions',
|
||||
);
|
||||
db.exec('DROP TABLE attendance_sessions');
|
||||
db.exec(
|
||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
||||
);
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
|
||||
// Rebuild attendance_records with FK on attendance_session_id
|
||||
const recordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all() as Array<{ from: string }>;
|
||||
const hasSessionFk = recordsFk.some((r) => r.from === 'attendance_session_id');
|
||||
if (!hasSessionFk) {
|
||||
db.exec(`
|
||||
CREATE TABLE attendance_records_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO attendance_records_new (
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
FROM attendance_records
|
||||
`);
|
||||
db.exec('DROP TABLE attendance_records');
|
||||
db.exec(
|
||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
||||
);
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
}
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
// Run foreign_key_check — should be clean
|
||||
const checkRows = db.prepare('PRAGMA foreign_key_check').all();
|
||||
if (checkRows.length > 0) {
|
||||
throw new Error(
|
||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
createPreMigrationSchema();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('preserves attendance_record.session_id after migration', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
db.exec(
|
||||
"INSERT INTO attendance_records (id, student_id, class_id, attendance_session_id, attendance_date, session, status) VALUES (1, 1, 1, 1, '2026-01-01', 'morning', 'present')",
|
||||
);
|
||||
|
||||
// Verify pre-migration state
|
||||
const preSessionFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
|
||||
.all();
|
||||
expect(preSessionFk.length).toBe(0);
|
||||
|
||||
const preRecordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all();
|
||||
expect(preRecordsFk.length).toBe(0);
|
||||
|
||||
// Run migration
|
||||
runMigration();
|
||||
|
||||
// Verify attendance_record still has correct attendance_session_id
|
||||
const record = db
|
||||
.prepare('SELECT * FROM attendance_records WHERE id = 1')
|
||||
.get() as Record<string, unknown>;
|
||||
expect(record).toBeDefined();
|
||||
expect(record.attendance_session_id).toBe(1);
|
||||
expect(record.attendance_date).toBe('2026-01-01');
|
||||
expect(record.session).toBe('morning');
|
||||
expect(record.status).toBe('present');
|
||||
|
||||
// Verify FKs now exist on both tables
|
||||
const postSessionFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
|
||||
.all();
|
||||
expect(postSessionFk.length).toBe(2);
|
||||
|
||||
const postRecordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all() as Array<{ from: string; table: string; on_delete: string }>;
|
||||
const sessionFk = postRecordsFk.find((r) => r.from === 'attendance_session_id');
|
||||
expect(sessionFk).toBeDefined();
|
||||
expect(sessionFk!.table).toBe('attendance_sessions');
|
||||
expect(sessionFk!.on_delete).toBe('SET NULL');
|
||||
|
||||
// RESTRICT still blocks class/schedule deletion
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM class_schedule WHERE id = 1');
|
||||
}).toThrow();
|
||||
|
||||
// Verify data survived the failed deletes
|
||||
const sessionAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(sessionAfter.cnt).toBe(1);
|
||||
|
||||
const recordAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_records WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(recordAfter.cnt).toBe(1);
|
||||
|
||||
const classAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM classes WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(classAfter.cnt).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, QueryRunner } from 'typeorm';
|
||||
import { uuidV7 } from '../common/uuid-v7';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,8 +10,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.ensureCourseAttendanceSchema();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
await this.protectAttendanceHistory();
|
||||
}
|
||||
|
||||
private async ensureAiConfigTable(): Promise<void> {
|
||||
@@ -100,6 +102,70 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCourseAttendanceSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']);
|
||||
const tableNames = new Set(tables.map((table) => table.name));
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
|
||||
if (!tableNames.has('attendance_sessions')) {
|
||||
const pkDef = isMySQL
|
||||
? 'id INTEGER PRIMARY KEY AUTO_INCREMENT'
|
||||
: 'id INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||
await runner.query(`
|
||||
CREATE TABLE attendance_sessions (
|
||||
${pkDef},
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const attendanceTable = await runner.getTable('attendance_records');
|
||||
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
|
||||
if (!columnNames.has('schedule_id')) {
|
||||
await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER');
|
||||
}
|
||||
if (!columnNames.has('attendance_session_id')) {
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER',
|
||||
);
|
||||
}
|
||||
|
||||
const createIndex = async (sql: string) => {
|
||||
try {
|
||||
await runner.query(sql);
|
||||
} catch {
|
||||
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
|
||||
}
|
||||
};
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)'
|
||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)'
|
||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async backfillOrganizations(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
@@ -246,4 +312,191 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows;
|
||||
if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`);
|
||||
}
|
||||
private async protectAttendanceHistory(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['attendance_sessions']);
|
||||
if (tables.length === 0) return;
|
||||
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
if (isMySQL) {
|
||||
await this.migrateMySQLAttendanceFKs(runner);
|
||||
} else {
|
||||
await this.migrateSQLiteAttendanceFKs(runner);
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateMySQLAttendanceFKs(runner: QueryRunner): Promise<void> {
|
||||
// Drop any existing FK constraint on schedule_id or class_id
|
||||
const fkColumns = ['schedule_id', 'class_id'];
|
||||
for (const col of fkColumns) {
|
||||
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
|
||||
SELECT CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'attendance_sessions'
|
||||
AND COLUMN_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
`, [col]);
|
||||
|
||||
for (const row of fkRows) {
|
||||
try {
|
||||
await runner.query(
|
||||
`ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``,
|
||||
);
|
||||
this.logger.log(`已移除考勤场次 FK 约束: ${row.CONSTRAINT_NAME}`);
|
||||
} catch {
|
||||
// constraint may have already been dropped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const constraints: Array<{ name: string; col: string; ref: string }> = [
|
||||
{ name: 'fk_as_schedule_protect', col: 'schedule_id', ref: 'class_schedule(id)' },
|
||||
{ name: 'fk_as_class_protect', col: 'class_id', ref: 'classes(id)' },
|
||||
];
|
||||
for (const c of constraints) {
|
||||
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
||||
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
|
||||
SELECT DELETE_RULE
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'attendance_sessions'
|
||||
AND CONSTRAINT_NAME = ?
|
||||
`, [c.name]);
|
||||
|
||||
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
||||
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ADD RESTRICT must throw on failure — no catch
|
||||
await runner.query(`
|
||||
ALTER TABLE attendance_sessions
|
||||
ADD CONSTRAINT ${c.name}
|
||||
FOREIGN KEY (${c.col}) REFERENCES ${c.ref}
|
||||
ON DELETE RESTRICT
|
||||
`);
|
||||
this.logger.log(`已添加考勤场次删除保护约束: ${c.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateSQLiteAttendanceFKs(runner: QueryRunner): Promise<void> {
|
||||
// SQLite cannot ALTER TABLE to add foreign keys.
|
||||
// Rebuild the table inside a transaction: create a new table with FK constraints,
|
||||
// copy all rows, drop old, rename new, then recreate indexes.
|
||||
const fkRows: Array<{ id: number }> = await runner.query(
|
||||
"PRAGMA foreign_key_list('attendance_sessions')",
|
||||
);
|
||||
if (fkRows.length > 0) return; // FKs already present
|
||||
|
||||
this.logger.log('正在重建 attendance_sessions 表以添加外键保护…');
|
||||
|
||||
// PRAGMA foreign_keys=OFF must be issued outside the transaction
|
||||
await runner.query('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
await runner.query('BEGIN');
|
||||
try {
|
||||
await runner.query(`
|
||||
CREATE TABLE attendance_sessions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await runner.query(`
|
||||
INSERT INTO attendance_sessions_new (
|
||||
id, schedule_id, class_id, lesson_date, status,
|
||||
started_by, started_at, completed_by, completed_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, schedule_id, class_id, lesson_date, status,
|
||||
started_by, started_at, completed_by, completed_at, created_at, updated_at
|
||||
FROM attendance_sessions
|
||||
`);
|
||||
await runner.query('DROP TABLE attendance_sessions');
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
||||
);
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
|
||||
// Rebuild attendance_records to add/protect FK on attendance_session_id
|
||||
const recordsFk = await runner.query(
|
||||
"PRAGMA foreign_key_list('attendance_records')",
|
||||
);
|
||||
const hasSessionFk = recordsFk.some(
|
||||
(r: { from: string }) => r.from === 'attendance_session_id',
|
||||
);
|
||||
if (!hasSessionFk) {
|
||||
await runner.query(`
|
||||
CREATE TABLE attendance_records_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await runner.query(`
|
||||
INSERT INTO attendance_records_new (
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
FROM attendance_records
|
||||
`);
|
||||
await runner.query('DROP TABLE attendance_records');
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
||||
);
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
}
|
||||
|
||||
// Verify foreign key integrity BEFORE committing the transaction.
|
||||
// If violations exist, the transaction rolls back and old tables are preserved.
|
||||
const checkRows = await runner.query('PRAGMA foreign_key_check');
|
||||
if (checkRows.length > 0) {
|
||||
throw new Error(
|
||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
||||
);
|
||||
}
|
||||
|
||||
await runner.query('COMMIT');
|
||||
this.logger.log('attendance_sessions 表外键保护重建完成');
|
||||
} catch (err) {
|
||||
await runner.query('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
await runner.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function mockRunner(overrides: {
|
||||
} = {}) {
|
||||
const release = jest.fn();
|
||||
const connect = jest.fn();
|
||||
const query = jest.fn();
|
||||
const query = jest.fn().mockResolvedValue([]);
|
||||
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
|
||||
const getTable = jest.fn().mockResolvedValue(
|
||||
overrides.getTable ?? { name: 'ai_config', columns: [] },
|
||||
@@ -31,19 +31,21 @@ function mockRunner(overrides: {
|
||||
return { release, connect, query, getTables, getTable };
|
||||
}
|
||||
|
||||
function createDataSource(runner: ReturnType<typeof mockRunner>) {
|
||||
function createDataSource(runner: ReturnType<typeof mockRunner>, dbType: string = 'better-sqlite3') {
|
||||
return {
|
||||
options: { type: 'better-sqlite3' },
|
||||
options: { type: dbType },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// Type to reach the private ensureAiConfigTable for testing
|
||||
// Type to reach private migration methods for testing
|
||||
interface MigrationsPrivate {
|
||||
ensureAiConfigTable(): Promise<void>;
|
||||
backfillOrganizations(): Promise<void>;
|
||||
normalizeClassDates(): Promise<void>;
|
||||
ensureCourseAttendanceSchema(): Promise<void>;
|
||||
protectAttendanceHistory(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
|
||||
@@ -176,3 +178,251 @@ describe('DatabaseMigrationsService — bootstrap failure handling', () => {
|
||||
expect(normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseMigrationsService — course attendance schema', () => {
|
||||
it('adds schedule linkage columns to an existing attendance_records table', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [
|
||||
{ name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
|
||||
],
|
||||
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||
});
|
||||
await bootstrapCourseAttendance(runner);
|
||||
|
||||
await service.ensureCourseAttendanceSchema();
|
||||
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'),
|
||||
);
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
||||
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||
});
|
||||
await bootstrapCourseAttendance(runner);
|
||||
await service.ensureCourseAttendanceSchema();
|
||||
|
||||
const createSql: string = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''))
|
||||
.find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? '';
|
||||
expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT');
|
||||
expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
||||
let service: MigrationsPrivate & DatabaseMigrationsService;
|
||||
|
||||
async function bootstrap(runner: ReturnType<typeof mockRunner>, dbType: string = 'better-sqlite3') {
|
||||
const dataSource = createDataSource(runner, dbType);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
|
||||
}
|
||||
|
||||
it('skips when attendance_sessions table is absent', async () => {
|
||||
const runner = mockRunner({ getTables: [] });
|
||||
await bootstrap(runner);
|
||||
await service.protectAttendanceHistory();
|
||||
expect(runner.query).not.toHaveBeenCalled();
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SQLite: exits early when FKs already exist', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
runner.query.mockResolvedValueOnce([{ id: 0 }]); // PRAGMA foreign_key_list returns rows
|
||||
await bootstrap(runner);
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
// Should not run any TABLE creation (rebuild)
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SQLite: rebuilds table with FK constraints when FKs are absent', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
// PRAGMA foreign_key_list for attendance_sessions → empty
|
||||
runner.query.mockResolvedValueOnce([]);
|
||||
// PRAGMA foreign_key_list for attendance_records → also empty (no FK yet)
|
||||
runner.query.mockResolvedValueOnce([]);
|
||||
await bootstrap(runner);
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// PRAGMA foreign_keys = OFF outside the transaction
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe(true);
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe(true);
|
||||
// attendance_records rebuilt with FK
|
||||
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true);
|
||||
// PRAGMA foreign_keys restored to ON and foreign_key_check runs
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_key_check'))).toBe(true);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SQLite: rolls back transaction when foreign_key_check finds violations', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
// Use mockImplementation to match by SQL content, not call position
|
||||
runner.query.mockImplementation((sql: string) => {
|
||||
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_list')) {
|
||||
return Promise.resolve([]); // FKs absent → trigger rebuild
|
||||
}
|
||||
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_check')) {
|
||||
return Promise.resolve([
|
||||
{ table: 'attendance_sessions', rowid: 42, parent: 'class_schedule', fkid: 0 },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner);
|
||||
|
||||
await expect(service.protectAttendanceHistory()).rejects.toThrow(
|
||||
/外键一致性检查失败/,
|
||||
);
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// The transaction should have been rolled back (ROLLBACK called)
|
||||
expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true);
|
||||
// COMMIT should NOT have been called
|
||||
expect(queries.some((q: string) => q.trim() === 'COMMIT')).toBe(false);
|
||||
// PRAGMA foreign_keys should still be restored
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
it('MySQL: drops old FKs and recreates both schedule_id and class_id as RESTRICT', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
// Mock: override SELECT CONSTRAINT_NAME and REFERENTIAL_CONSTRAINTS queries
|
||||
runner.query.mockImplementation((sql: string, params?: string[]) => {
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||
if (params?.[0] === 'schedule_id') {
|
||||
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_schedule_cascade' }]);
|
||||
}
|
||||
if (params?.[0] === 'class_id') {
|
||||
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_class_cascade' }]);
|
||||
}
|
||||
}
|
||||
// REFERENTIAL_CONSTRAINTS check — constraint does not yet exist
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner, 'mysql');
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// Drops old FKs
|
||||
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true);
|
||||
// Checks REFERENTIAL_CONSTRAINTS before ADD
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')
|
||||
)).toBe(true);
|
||||
// Creates new RESTRICT FKs
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MySQL: throws when ADD CONSTRAINT RESTRICT fails', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
const addError = new Error('Cannot add foreign key constraint');
|
||||
runner.query.mockImplementation((sql: string, params?: string[]) => {
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (typeof sql === 'string' && sql.includes('ADD CONSTRAINT')) {
|
||||
return Promise.reject(addError);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner, 'mysql');
|
||||
await expect(service.protectAttendanceHistory()).rejects.toThrow('Cannot add foreign key constraint');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MySQL: skips ADD when RESTRICT constraint already confirmed via information_schema', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
runner.query.mockImplementation((sql: string, params?: string[]) => {
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
// REFERENTIAL_CONSTRAINTS confirms RESTRICT already present
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
|
||||
return Promise.resolve([{ DELETE_RULE: 'RESTRICT' }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner, 'mysql');
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// No ADD CONSTRAINT calls
|
||||
expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function bootstrapCourseAttendance(runner: ReturnType<typeof mockRunner>) {
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user