import Database from 'better-sqlite3'; type SqliteDB = InstanceType; /** * 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; 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; 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); }); });