/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; // We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules describe('db/migrations/migrate.js - runMigrations', () => { let calls; let runMigrations; let prevExitCode; beforeEach(async () => { calls = { fs: { existsSync: [], mkdirSync: [], readdirSync: [], readFileSync: [] }, sql: { getConnection: 0, tableExists: false, query: [], execute: [], withTransaction: [], optimize: 0, }, logs: { info: [], warn: [], error: [] }, }; // Mock fs to avoid touching disk const fsMock = { existsSync: (p) => { calls.fs.existsSync.push(p); return true; }, mkdirSync: (p, opts) => { calls.fs.mkdirSync.push({ p, opts }); }, readdirSync: (p) => { calls.fs.readdirSync.push(p); return []; }, readFileSync: (p) => { calls.fs.readFileSync.push(p); return Buffer.from('dummy'); }, }; // Mock crypto sha256 const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'sha256sum' }) }), }; // Mock logger const loggerMock = { info: (...a) => calls.logs.info.push(a), warn: (...a) => calls.logs.warn.push(a), error: (...a) => calls.logs.error.push(a), }; // Mock SqliteConnection const sqlMock = { getConnection: () => { calls.sql.getConnection += 1; return {}; }, tableExists: () => calls.sql.tableExists, query: (sql) => { calls.sql.query.push(sql); return []; }, execute: (sql, params) => { calls.sql.execute.push({ sql, params }); return { changes: 1 }; }, withTransaction: (cb) => { calls.sql.withTransaction.push(true); const db = { prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }), }; return cb(db); }, optimize: () => { calls.sql.optimize += 1; }, }; const path = await import('node:path'); const ROOT = path.resolve('.'); const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js'); const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js'); vi.resetModules(); vi.doMock('fs', () => ({ default: fsMock, ...fsMock })); vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock })); vi.doMock(sqlPath, () => ({ default: sqlMock })); vi.doMock(loggerPath, () => ({ default: loggerMock })); const mod = await import('../../../lib/services/storage/migrations/migrate.js'); runMigrations = mod.runMigrations; // remember original exitCode to restore later prevExitCode = process.exitCode; }); afterEach(() => { // restore original process.exitCode process.exitCode = prevExitCode; }); it('logs and returns when no migration files are found', async () => { await runMigrations(); expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).toBe(true); expect(calls.sql.getConnection).toBe(0); expect(calls.sql.optimize).toBe(0); }); it('applies a single new migration inside a transaction and records it', async () => { // Re-mock with one file and module loader const fsMock = { existsSync: () => true, mkdirSync: () => {}, readdirSync: () => ['1.init.js'], readFileSync: () => Buffer.from('dummy'), }; const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'abc' }) }) }; const loggerMock = { info: (...a) => calls.logs.info.push(a), warn: (...a) => calls.logs.warn.push(a), error: (...a) => calls.logs.error.push(a), }; const sqlMock = { getConnection: () => { calls.sql.getConnection += 1; return {}; }, tableExists: () => false, // schema_migrations not present yet query: () => [], execute: (sql, params) => { calls.sql.execute.push({ sql, params }); return { changes: 1 }; }, withTransaction: (cb) => { calls.sql.withTransaction.push(true); const db = { exec: () => {}, prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }), }; return cb(db); }, optimize: () => { calls.sql.optimize += 1; }, }; // The migration module: exports up(db) const migrationModule = { up: (db) => { db.exec && db.exec('CREATE TABLE schema_migrations(name TEXT)'); }, }; const path = await import('node:path'); const ROOT = path.resolve('.'); const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js'); const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js'); // Use global importer hook to bypass dynamic import globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule; vi.resetModules(); vi.doMock('fs', () => ({ default: fsMock, ...fsMock })); vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock })); vi.doMock(sqlPath, () => ({ default: sqlMock })); vi.doMock(loggerPath, () => ({ default: loggerMock })); const mod = await import('../../../lib/services/storage/migrations/migrate.js'); runMigrations = mod.runMigrations; await runMigrations(); // Should have started a transaction and inserted into schema_migrations expect(calls.sql.withTransaction.length).toBe(1); const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations')); expect(!!inserted).toBe(true); expect(calls.sql.optimize).toBe(1); }); it('skips already executed migration with same checksum', async () => { const fsMock = { existsSync: () => true, mkdirSync: () => {}, readdirSync: () => ['1.init.js'], readFileSync: () => Buffer.from('dummy'), }; const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'same' }) }) }; const loggerMock = { info: (...a) => calls.logs.info.push(a), warn: (...a) => calls.logs.warn.push(a), error: (...a) => calls.logs.error.push(a), }; const sqlMock = { getConnection: () => { calls.sql.getConnection += 1; return {}; }, tableExists: () => true, query: () => [{ name: '1.init.js', checksum: 'same' }], execute: (sql, params) => { calls.sql.execute.push({ sql, params }); return { changes: 1 }; }, withTransaction: (cb) => { calls.sql.withTransaction.push(true); const db = { prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }) }; return cb(db); }, optimize: () => { calls.sql.optimize += 1; }, }; const path = await import('node:path'); const ROOT = path.resolve('.'); const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js'); const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js'); globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} }); vi.resetModules(); vi.doMock('fs', () => ({ default: fsMock, ...fsMock })); vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock })); vi.doMock(sqlPath, () => ({ default: sqlMock })); vi.doMock(loggerPath, () => ({ default: loggerMock })); const mod = await import('../../../lib/services/storage/migrations/migrate.js'); runMigrations = mod.runMigrations; await runMigrations(); // Should not run transaction because it's skipped expect(calls.sql.withTransaction.length).toBe(0); expect(calls.sql.optimize).toBe(1); }); it('aborts with exitCode=1 when a migration throws, without applying insert', async () => { const fsMock = { existsSync: () => true, mkdirSync: () => {}, readdirSync: () => ['1.bad.js'], readFileSync: () => Buffer.from('dummy'), }; const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'bad' }) }) }; const loggerMock = { info: (...a) => calls.logs.info.push(a), warn: (...a) => calls.logs.warn.push(a), error: (...a) => calls.logs.error.push(a), }; const sqlMock = { getConnection: () => { calls.sql.getConnection += 1; return {}; }, tableExists: () => false, query: () => [], execute: (sql, params) => { calls.sql.execute.push({ sql, params }); return { changes: 1 }; }, withTransaction: (cb) => { calls.sql.withTransaction.push(true); const db = { exec: () => {}, prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }), }; return cb(db); }, optimize: () => { calls.sql.optimize += 1; }, }; const path = await import('node:path'); const ROOT = path.resolve('.'); globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => { throw new Error('boom'); }, }); const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js'); const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js'); vi.resetModules(); vi.doMock('fs', () => ({ default: fsMock, ...fsMock })); vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock })); vi.doMock(sqlPath, () => ({ default: sqlMock })); vi.doMock(loggerPath, () => ({ default: loggerMock })); const mod = await import('../../../lib/services/storage/migrations/migrate.js'); runMigrations = mod.runMigrations; await runMigrations(); expect(process.exitCode).toBe(1); // No insert into schema_migrations should be recorded since transaction failed const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations')); expect(inserted).toBe(undefined); }); });