import { TestingModule, Test } from '@nestjs/testing'; import { DatabaseMigrationsService } from './database-migrations.service'; import { getDataSourceToken } from '@nestjs/typeorm'; interface MockColumn { name: string; } interface MockTable { name: string; columns: MockColumn[]; } interface MockRunner { release: jest.Mock; connect: jest.Mock; query: jest.Mock; getTables: jest.Mock; getTable: jest.Mock; } function mockRunner(overrides: { getTables?: MockTable[]; getTable?: MockTable; queryError?: Error; } = {}) { const release = jest.fn(); const connect = 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: [] }, ); if (overrides.queryError) { query.mockRejectedValue(overrides.queryError); } return { release, connect, query, getTables, getTable } satisfies MockRunner; } function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3') { return { options: { type: dbType }, createQueryRunner: jest.fn().mockReturnValue(runner), transaction: jest.fn(), }; } // Type to reach private migration methods for testing interface MigrationsPrivate { ensureAiConfigTable(): Promise; ensureSyncStateLeaseColumns(): Promise; backfillOrganizations(): Promise; normalizeClassDates(): Promise; ensureCourseAttendanceSchema(): Promise; protectAttendanceHistory(): Promise; removeUnusedClassroomColumns(): Promise; } describe('DatabaseMigrationsService — ensureAiConfigTable', () => { let service: MigrationsPrivate & DatabaseMigrationsService; async function bootstrap(runner: MockRunner) { const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ providers: [ DatabaseMigrationsService, { provide: getDataSourceToken(), useValue: dataSource }, ], }).compile(); service = module.get( DatabaseMigrationsService, ); } it('creates table + index when ai_config does not exist', async () => { const runner = mockRunner({ getTables: [] }); await bootstrap(runner); await service.ensureAiConfigTable(); expect(runner.connect).toHaveBeenCalled(); expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config')); expect(runner.query).toHaveBeenCalledWith( expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'), ); expect(runner.release).toHaveBeenCalled(); }); it('skips ALTER when table exists with all columns', async () => { const allColumns: MockColumn[] = [ { name: 'id' }, { name: 'singleton_key' }, { name: 'provider' }, { name: 'base_url' }, { name: 'encrypted_api_key' }, { name: 'api_key_iv' }, { name: 'api_key_auth_tag' }, { name: 'key_last4' }, { name: 'default_model' }, { name: 'enabled' }, { name: 'timeout_ms' }, { name: 'verified' }, { name: 'last_tested_at' }, { name: 'last_test_latency_ms' }, { name: 'created_at' }, { name: 'updated_at' }, ]; const runner = mockRunner({ getTables: [{ name: 'ai_config', columns: allColumns }], getTable: { name: 'ai_config', columns: allColumns }, }); await bootstrap(runner); await service.ensureAiConfigTable(); expect(runner.connect).toHaveBeenCalled(); // Should NOT issue any ALTER TABLE const alterCalls = (runner.query as jest.Mock).mock.calls.filter( (c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'), ); expect(alterCalls).toHaveLength(0); expect(runner.release).toHaveBeenCalled(); }); it('adds missing column via ALTER TABLE', async () => { // Table has most columns but is missing last_test_latency_ms const missingOne: MockColumn[] = [ { name: 'id' }, { name: 'singleton_key' }, { name: 'provider' }, { name: 'base_url' }, { name: 'encrypted_api_key' }, { name: 'api_key_iv' }, { name: 'api_key_auth_tag' }, { name: 'key_last4' }, { name: 'default_model' }, { name: 'enabled' }, { name: 'timeout_ms' }, { name: 'verified' }, { name: 'last_tested_at' }, // last_test_latency_ms missing { name: 'created_at' }, { name: 'updated_at' }, ]; const runner = mockRunner({ getTables: [{ name: 'ai_config', columns: missingOne }], getTable: { name: 'ai_config', columns: missingOne }, }); await bootstrap(runner); await service.ensureAiConfigTable(); expect(runner.connect).toHaveBeenCalled(); expect(runner.query).toHaveBeenCalledWith( expect.stringContaining('ALTER TABLE ai_config ADD COLUMN last_test_latency_ms INT'), ); expect(runner.release).toHaveBeenCalled(); }); it('releases runner even when query throws', async () => { const runner = mockRunner({ getTables: [], queryError: new Error('BOOM') }); await bootstrap(runner); await expect(service.ensureAiConfigTable()).rejects.toThrow('BOOM'); expect(runner.release).toHaveBeenCalled(); }); }); describe('DatabaseMigrationsService — bootstrap failure handling', () => { it('fails application bootstrap when the required ai_config migration fails', async () => { const runner = mockRunner(); const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ providers: [ DatabaseMigrationsService, { provide: getDataSourceToken(), useValue: dataSource }, ], }).compile(); const service = module.get(DatabaseMigrationsService); jest.spyOn(service, 'ensureAiConfigTable').mockRejectedValue(new Error('migration failed')); const backfill = jest.spyOn(service, 'backfillOrganizations').mockResolvedValue(); const normalize = jest.spyOn(service, 'normalizeClassDates').mockResolvedValue(); await expect(service.onApplicationBootstrap()).rejects.toThrow('migration failed'); expect(backfill).not.toHaveBeenCalled(); 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' }] }, }); const service = 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('adds the configurable attendance window to existing schedules', async () => { const runner = mockRunner({ getTables: [ { name: 'class_schedule', columns: [{ name: 'id' }] }, { name: 'attendance_records', columns: [{ name: 'id' }] }, { name: 'attendance_sessions', columns: [{ name: 'id' }] }, ], }); runner.getTable.mockImplementation(async (name: string) => name === 'class_schedule' ? { name, columns: [{ name: 'id' }] } : { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] }, ); const service = await bootstrapCourseAttendance(runner); await service.ensureCourseAttendanceSchema(); expect(runner.query).toHaveBeenCalledWith( expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'), ); }); 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' }] }, }); const service = 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: 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); } 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(); }); }); describe('DatabaseMigrationsService — classroom cleanup', () => { let cleanupService: MigrationsPrivate & DatabaseMigrationsService; async function bootstrapClassroomCleanup(runner: MockRunner) { const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ providers: [ DatabaseMigrationsService, { provide: getDataSourceToken(), useValue: dataSource }, ], }).compile(); cleanupService = module.get(DatabaseMigrationsService); } it('drops legacy classroom fields when present', async () => { const runner = mockRunner({ getTables: [{ name: 'classrooms', columns: [] }], getTable: { name: 'classrooms', columns: [{ name: 'id' }, { name: 'course_type' }, { name: 'supervisor' }], }, }); await bootstrapClassroomCleanup(runner); await cleanupService.removeUnusedClassroomColumns(); expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN course_type'); expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN supervisor'); expect(runner.release).toHaveBeenCalled(); }); it('does nothing when the classrooms table is absent', async () => { const runner = mockRunner({ getTables: [] }); await bootstrapClassroomCleanup(runner); await cleanupService.removeUnusedClassroomColumns(); expect(runner.query).not.toHaveBeenCalled(); expect(runner.release).toHaveBeenCalled(); }); }); async function bootstrapCourseAttendance(runner: MockRunner) { const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ providers: [ DatabaseMigrationsService, { provide: getDataSourceToken(), useValue: dataSource }, ], }).compile(); return module.get(DatabaseMigrationsService); }