forked from wangziqi/gongxue-base
- 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>
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|