import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface MockPgClient { query: (queryString: string) => unknown; release: () => void; } interface MockPgState { migrationsTableExistsCall: () => { rows: { migrationsTableExists: boolean }[]; }; migrationsTableCall: () => { rows: unknown[] }; connectCallback: (() => Promise) | undefined; } const mockState = vi.hoisted(() => { const state: MockPgState = { migrationsTableExistsCall: () => { return { rows: [{ migrationsTableExists: true }] }; }, migrationsTableCall: () => { return { rows: [] }; }, connectCallback: undefined, }; const defaultConnectCallback = async () => { return { query: (queryString: string) => { if (queryString.startsWith('SELECT EXISTS')) { return state.migrationsTableExistsCall(); } else { return state.migrationsTableCall(); } }, release: () => { return; }, }; }; state.connectCallback = defaultConnectCallback; return { state, defaultConnectCallback, }; }); vi.mock('pg', async () => { const original = await vi.importActual('pg'); class MockPool { connect = async () => { return mockState.state.connectCallback!(); }; end = () => { return; }; } return { ...original, Pool: MockPool, }; }); import { compareMigrationHashes } from './compare-migration-hashes'; import * as compareHelpers from './compare-migration-hashes-helpers'; import { CompareMigrationHashesErrorCallback, MigrationRecord } from './types'; describe('compareMigrationHashes', () => { let errorMessage: string; let errorMigrationRecords: MigrationRecord[] | undefined; let mockGetFileMigrationRecords: ReturnType; const mockErrorCallback: CompareMigrationHashesErrorCallback = ( message: string, mismatchedRecords?: MigrationRecord[], ): void => { errorMessage = message; errorMigrationRecords = mismatchedRecords; }; beforeEach(() => { mockGetFileMigrationRecords = vi.spyOn( compareHelpers, 'getFileMigrationRecords', ); }); afterEach(() => { vi.restoreAllMocks(); mockState.state.connectCallback = mockState.defaultConnectCallback; mockState.state.migrationsTableExistsCall = () => { return { rows: [{ migrationsTableExists: true }] }; }; mockState.state.migrationsTableCall = () => { return { rows: [] }; }; }); const migration000001: MigrationRecord = { filename: '000001.sql', fullFilename: '000001-initial-schema.sql', previousHash: null, hash: `sha1:dc45d94de06a32ed0504f2c3d8a74fbbc367f01c`, source: 'file', }; const migration000002: MigrationRecord = { filename: '000002.sql', fullFilename: '000002-update-schema.sql', previousHash: 'sha1:dc45d94de06a32ed0504f2c3d8a74fbbc367f01c', hash: `sha1:30d91b270e6476df4f8b0e3183e025f0dc68dcd8`, source: 'file', }; const migration000003: MigrationRecord = { filename: '000003.sql', fullFilename: '000003-extend-schema.sql', previousHash: 'sha1:30d91b270e6476df4f8b0e3183e025f0dc68dcd8', hash: `sha1:a929160647965e9dc0bdd9678b11f5bc76fdcdd9`, source: 'file', }; const dbMigration000001 = { ...migration000001, source: 'database', fullFilename: null, }; const dbMigration000002 = { ...migration000002, source: 'database', fullFilename: null, }; const dbMigration000003 = { ...migration000003, source: 'database', fullFilename: null, }; describe('error cases', () => { it('Passed settings object has an empty connection string - custom error is thrown', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([])); // Act & Assert await expect( compareMigrationHashes({}, mockErrorCallback), ).rejects.toThrow('Database connection string is not set up.'); }); it('Connection to database fails - original error is thrown as is', async () => { // Arrange mockState.state.connectCallback = async () => { throw new Error('database "test_database" does not exist'); }; mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([])); // Act & Assert await expect( compareMigrationHashes( { connectionString: 'some connection string' }, mockErrorCallback, ), ).rejects.toThrow('database "test_database" does not exist'); }); it('Migrations table exists check fails - original error is thrown', async () => { // Arrange mockState.state.migrationsTableExistsCall = () => { throw new Error('Query failed for whatever reason.'); }; mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([])); // Act & Assert await expect( compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ), ).rejects.toThrow('Query failed for whatever reason.'); }); it('Migrations history request to database fails - original error is thrown', async () => { // Arrange mockState.state.migrationsTableCall = () => { throw new Error('Query failed for whatever reason.'); }; mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([])); // Act & Assert await expect( compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ), ).rejects.toThrow('Query failed for whatever reason.'); }); }); describe('migration hashes match', () => { it('if migration history table does not exist', async () => { // Arrange mockState.state.migrationsTableExistsCall = () => { return { rows: [{ migrationsTableExists: false }] }; }; mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([])); // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBeUndefined(); expect(errorMigrationRecords).toBeUndefined(); }); it('if migration history is empty and no committed migrations', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([])); mockState.state.migrationsTableCall = () => { return { rows: [] }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBeUndefined(); expect(errorMigrationRecords).toBeUndefined(); }); it('if migration history is empty and there are committed migration files', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([migration000001, migration000002]), ); mockState.state.migrationsTableCall = () => { return { rows: [] }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBeUndefined(); expect(errorMigrationRecords).toBeUndefined(); }); it('if migration history has migrations and new migration files were committed', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([migration000001, migration000002, migration000003]), ); mockState.state.migrationsTableCall = () => { return { rows: [dbMigration000001] }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBeUndefined(); expect(errorMigrationRecords).toBeUndefined(); }); it('if migration history and committed migrations have same records', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([migration000001, migration000002, migration000003]), ); mockState.state.migrationsTableCall = () => { return { rows: [dbMigration000001, dbMigration000002, dbMigration000003], }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBeUndefined(); expect(errorMigrationRecords).toBeUndefined(); }); }); describe('migration hashes do not match', () => { it('if migration history has records and there is no committed migrations', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([])); mockState.state.migrationsTableCall = () => { return { rows: [dbMigration000001, dbMigration000002, dbMigration000003], }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBe( 'Migration history contains more records than committed migration files.', ); expect(errorMigrationRecords).toHaveLength(3); expect(errorMigrationRecords).toEqual( expect.arrayContaining([ dbMigration000001, dbMigration000002, dbMigration000003, ]), ); }); it('if migration history has more records than there is committed files', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([migration000001]), ); mockState.state.migrationsTableCall = () => { return { rows: [dbMigration000001, dbMigration000002, dbMigration000003], }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBe( 'Migration history contains more records than committed migration files.', ); expect(errorMigrationRecords).toHaveLength(2); expect(errorMigrationRecords).toEqual( expect.arrayContaining([dbMigration000002, dbMigration000003]), ); }); it('if migration history record with `hash` that does not match committed record', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([migration000001, migration000002, migration000003]), ); const brokenDbMigration000002 = { ...dbMigration000002, hash: 'sha1:41d91b380e6476df4f8b0e3183e025f0dc68dcd8', }; const brokenDbMigration000003 = { ...dbMigration000003, hash: 'sha1:a929171757965e9dc0bdd9678b11f5bc76fdcdd9', }; mockState.state.migrationsTableCall = () => { return { rows: [ dbMigration000001, brokenDbMigration000002, brokenDbMigration000003, ], }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBe( 'Found hash mismatches between committed migration files and migration history records.', ); expect(errorMigrationRecords).toHaveLength(2); expect(errorMigrationRecords).toEqual( expect.arrayContaining([migration000002, migration000003]), ); }); it('if migration history record with `previousHash` that does not match committed record', async () => { // Arrange mockGetFileMigrationRecords.mockImplementation(() => Promise.resolve([migration000001, migration000002, migration000003]), ); const brokenDbMigration000002 = { ...dbMigration000002, previousHash: 'sha1:41d91b380e6476df4f8b0e3183e025f0dc68dcd8', }; const brokenDbMigration000003 = { ...dbMigration000003, previousHash: 'sha1:a929171757965e9dc0bdd9678b11f5bc76fdcdd9', }; mockState.state.migrationsTableCall = () => { return { rows: [ migration000001, brokenDbMigration000002, brokenDbMigration000003, ], }; }; // Act await compareMigrationHashes( { connectionString: 'valid connection string' }, mockErrorCallback, ); // Assert expect(errorMessage).toBe( 'Found hash mismatches between committed migration files and migration history records.', ); expect(errorMigrationRecords).toHaveLength(2); expect(errorMigrationRecords).toEqual( expect.arrayContaining([migration000002, migration000003]), ); }); }); });