UNPKG

@shagital/atomic-lock

Version:

Universal atomic locking with pluggable drivers (Redis, SQLite, File, Memory)

688 lines (527 loc) 25.1 kB
import { SQLiteLockDriver } from '../src/drivers/sqlite-driver' describe('SQLiteLockDriver', () => { let driver: SQLiteLockDriver let mockDb: any let mockPrepare: jest.Mock let mockExec: jest.Mock let mockTransaction: jest.Mock beforeEach(() => { jest.clearAllMocks() // Create mock prepared statement const mockStmt = { run: jest.fn().mockReturnValue({ changes: 1 }), get: jest.fn() } // Create mock database mockPrepare = jest.fn().mockReturnValue(mockStmt) mockExec = jest.fn() mockTransaction = jest.fn() mockDb = { exec: mockExec, prepare: mockPrepare, transaction: mockTransaction, close: jest.fn() } // Mock transaction to execute callback immediately mockTransaction.mockImplementation((callback) => { return () => callback() }) }) describe('Constructor and Initialization', () => { it('should create driver with default table name', () => { driver = new SQLiteLockDriver({ db: mockDb }) expect(driver).toBeDefined() expect(mockExec).toHaveBeenCalledTimes(2) // Table creation + index creation // Check table creation SQL const tableCall = mockExec.mock.calls[0][0] expect(tableCall).toContain('CREATE TABLE IF NOT EXISTS atomic_locks') expect(tableCall).toContain('lock_key TEXT PRIMARY KEY') expect(tableCall).toContain('lock_value TEXT NOT NULL') expect(tableCall).toContain('expires_at INTEGER NOT NULL') expect(tableCall).toContain('created_at INTEGER NOT NULL') // Check index creation SQL const indexCall = mockExec.mock.calls[1][0] expect(indexCall).toContain('CREATE INDEX IF NOT EXISTS idx_atomic_locks_expires_at') expect(indexCall).toContain('ON atomic_locks(expires_at)') }) it('should create driver with custom table name', () => { driver = new SQLiteLockDriver({ db: mockDb, tableName: 'custom_locks' }) expect(mockExec).toHaveBeenCalledTimes(2) // Check custom table name is used const tableCall = mockExec.mock.calls[0][0] expect(tableCall).toContain('CREATE TABLE IF NOT EXISTS custom_locks') const indexCall = mockExec.mock.calls[1][0] expect(indexCall).toContain('idx_custom_locks_expires_at') expect(indexCall).toContain('ON custom_locks(expires_at)') }) it('should handle database exec errors during initialization', () => { mockExec.mockImplementation(() => { throw new Error('Database initialization failed') }) expect(() => { new SQLiteLockDriver({ db: mockDb }) }).toThrow('Database initialization failed') }) }) describe('tryAcquire', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() // Clear initialization calls }) it('should successfully acquire lock when key is available', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockStmt = { run: jest.fn() } mockPrepare.mockReturnValueOnce(mockStmt) // cleanup statement mockPrepare.mockReturnValueOnce(mockStmt) // insert statement const result = await driver.tryAcquire('test-key', 'lock-value-123', 30) expect(result).toBe(true) expect(mockPrepare).toHaveBeenCalledTimes(2) // Check cleanup call expect(mockPrepare).toHaveBeenNthCalledWith(1, expect.stringContaining('DELETE FROM atomic_locks')) expect(mockStmt.run).toHaveBeenNthCalledWith(1, 'test-key', mockNow) // Check insert call expect(mockPrepare).toHaveBeenNthCalledWith(2, expect.stringContaining('INSERT INTO atomic_locks')) expect(mockStmt.run).toHaveBeenNthCalledWith(2, 'test-key', 'lock-value-123', mockNow + 30000, mockNow) }) it('should return false when lock already exists (SQLITE_CONSTRAINT_PRIMARYKEY)', async () => { const mockStmt = { run: jest.fn() } mockPrepare.mockReturnValueOnce(mockStmt) // cleanup statement mockPrepare.mockReturnValueOnce(mockStmt) // insert statement // Mock insert to throw primary key constraint error const error = new Error('UNIQUE constraint failed') as any error.code = 'SQLITE_CONSTRAINT_PRIMARYKEY' mockStmt.run .mockReturnValueOnce(undefined) // cleanup succeeds .mockImplementationOnce(() => { throw error }) // insert fails const result = await driver.tryAcquire('existing-key', 'value', 30) expect(result).toBe(false) }) it('should throw error for non-constraint database errors', async () => { const mockStmt = { run: jest.fn() } mockPrepare.mockReturnValueOnce(mockStmt) // cleanup statement mockPrepare.mockReturnValueOnce(mockStmt) // insert statement const error = new Error('Database connection lost') as any error.code = 'SQLITE_IOERR' mockStmt.run .mockReturnValueOnce(undefined) // cleanup succeeds .mockImplementationOnce(() => { throw error }) // insert fails with non-constraint error await expect(driver.tryAcquire('test-key', 'value', 30)) .rejects.toThrow('Database connection lost') }) it('should handle different expiry times and values', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockStmt = { run: jest.fn() } mockPrepare.mockReturnValue(mockStmt) await driver.tryAcquire('test-key', 'custom-value', 120) // Check that correct expiry time is calculated expect(mockStmt.run).toHaveBeenLastCalledWith( 'test-key', 'custom-value', mockNow + 120000, // 120 seconds mockNow ) }) it('should use custom table name in SQL statements', async () => { driver = new SQLiteLockDriver({ db: mockDb, tableName: 'custom_locks' }) jest.clearAllMocks() const mockStmt = { run: jest.fn() } mockPrepare.mockReturnValue(mockStmt) await driver.tryAcquire('test-key', 'value', 30) expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM custom_locks')) expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO custom_locks')) }) }) describe('tryAcquireMultiple', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should successfully acquire multiple locks when all keys are available', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockStmt = { run: jest.fn(), get: jest.fn().mockReturnValue({ count: 0 }) // No existing locks } mockPrepare.mockReturnValue(mockStmt) // Mock transaction to return the result of the callback mockTransaction.mockImplementation((callback) => { return () => callback() }) const result = await driver.tryAcquireMultiple( ['key1', 'key2', 'key3'], 'multi-value', 45 ) expect(result).toBe(true) expect(mockTransaction).toHaveBeenCalledWith(expect.any(Function)) // Should prepare 3 statements: cleanup, check, insert expect(mockPrepare).toHaveBeenCalledTimes(3) // Check cleanup statement - more flexible regex that handles whitespace expect(mockPrepare).toHaveBeenNthCalledWith(1, expect.stringMatching(/DELETE\s+FROM\s+\w+\s+WHERE\s+lock_key\s+IN\s*\(\?,\?,\?\)/)) expect(mockStmt.run).toHaveBeenCalledWith('key1', 'key2', 'key3', mockNow) // Check existence check statement expect(mockPrepare).toHaveBeenNthCalledWith(2, expect.stringMatching(/SELECT\s+COUNT\(\*\)\s+as\s+count\s+FROM\s+\w+\s+WHERE\s+lock_key\s+IN\s*\(\?,\?,\?\)/)) expect(mockStmt.get).toHaveBeenCalledWith('key1', 'key2', 'key3', mockNow) // Check insert statement (called 3 times for 3 keys) expect(mockStmt.run).toHaveBeenCalledWith('key1', 'multi-value', mockNow + 45000, mockNow) expect(mockStmt.run).toHaveBeenCalledWith('key2', 'multi-value', mockNow + 45000, mockNow) expect(mockStmt.run).toHaveBeenCalledWith('key3', 'multi-value', mockNow + 45000, mockNow) }) it('should return false when any locks already exist', async () => { const mockStmt = { run: jest.fn(), get: jest.fn().mockReturnValue({ count: 1 }) // 1 existing lock } mockPrepare.mockReturnValue(mockStmt) mockTransaction.mockImplementation((callback) => { return () => callback() }) const result = await driver.tryAcquireMultiple(['key1', 'key2'], 'value', 30) expect(result).toBe(false) // Should not proceed to insert stage expect(mockPrepare).toHaveBeenCalledTimes(2) // Only cleanup and check, no insert }) it('should handle empty keys array', async () => { const mockStmt = { run: jest.fn(), get: jest.fn().mockReturnValue({ count: 0 }) } mockPrepare.mockReturnValue(mockStmt) mockTransaction.mockImplementation((callback) => { return () => callback() }) const result = await driver.tryAcquireMultiple([], 'value', 30) expect(result).toBe(true) // Should still execute but with empty IN clause expect(mockPrepare).toHaveBeenCalledWith(expect.stringMatching(/IN \(\)/)) }) it('should handle single key in array', async () => { const mockStmt = { run: jest.fn(), get: jest.fn().mockReturnValue({ count: 0 }) } mockPrepare.mockReturnValue(mockStmt) mockTransaction.mockImplementation((callback) => { return () => callback() }) const result = await driver.tryAcquireMultiple(['single-key'], 'value', 30) expect(result).toBe(true) expect(mockPrepare).toHaveBeenCalledWith(expect.stringMatching(/IN \(\?\)/)) }) it('should use custom table name in transaction', async () => { driver = new SQLiteLockDriver({ db: mockDb, tableName: 'custom_locks' }) jest.clearAllMocks() const mockStmt = { run: jest.fn(), get: jest.fn().mockReturnValue({ count: 0 }) } mockPrepare.mockReturnValue(mockStmt) mockTransaction.mockImplementation((callback) => { return () => callback() }) await driver.tryAcquireMultiple(['key1'], 'value', 30) expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM custom_locks')) expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('SELECT COUNT(*) as count FROM custom_locks')) expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO custom_locks')) }) }) describe('release', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should successfully release lock with correct value', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 1 }) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.release('test-key', 'correct-value') expect(result).toBe(true) expect(mockPrepare).toHaveBeenCalledWith(expect.stringMatching( /DELETE\s+FROM\s+\w+\s+WHERE\s+lock_key\s*=\s*\?\s+AND\s+lock_value\s*=\s*\?/ )) expect(mockStmt.run).toHaveBeenCalledWith('test-key', 'correct-value') }) it('should return false when no rows affected (wrong value or non-existent key)', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 0 }) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.release('test-key', 'wrong-value') expect(result).toBe(false) }) it('should use custom table name', async () => { driver = new SQLiteLockDriver({ db: mockDb, tableName: 'custom_locks' }) jest.clearAllMocks() const mockStmt = { run: jest.fn().mockReturnValue({ changes: 1 }) } mockPrepare.mockReturnValue(mockStmt) await driver.release('test-key', 'value') expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM custom_locks')) }) }) describe('releaseMultiple', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should release multiple locks and return count', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 3 }) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.releaseMultiple(['key1', 'key2', 'key3'], 'multi-value') expect(result).toBe(3) expect(mockPrepare).toHaveBeenCalledWith(expect.stringMatching( /DELETE\s+FROM\s+\w+\s+WHERE\s+lock_key\s+IN\s*\(\?,\?,\?\)\s+AND\s+lock_value\s*=\s*\?/ )) expect(mockStmt.run).toHaveBeenCalledWith('key1', 'key2', 'key3', 'multi-value') }) it('should return 0 when no locks match', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 0 }) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.releaseMultiple(['key1', 'key2'], 'non-matching-value') expect(result).toBe(0) }) it('should return partial count when some locks match', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 2 }) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.releaseMultiple(['key1', 'key2', 'key3'], 'partial-value') expect(result).toBe(2) // Only 2 out of 3 matched }) it('should handle empty keys array', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 0 }) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.releaseMultiple([], 'value') expect(result).toBe(0) expect(mockPrepare).toHaveBeenCalledWith(expect.stringMatching(/IN \(\)/)) }) it('should handle single key in array', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 1 }) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.releaseMultiple(['single-key'], 'value') expect(result).toBe(1) expect(mockPrepare).toHaveBeenCalledWith(expect.stringMatching(/IN \(\?\)/)) }) }) describe('exists', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should return true when lock exists and is not expired', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockStmt = { run: jest.fn(), // cleanup statement get: jest.fn().mockReturnValue({ exists: true }) // select statement } mockPrepare.mockReturnValue(mockStmt) const result = await driver.exists('test-key') expect(result).toBe(true) // Should first clean up expired locks - use flexible regex for whitespace expect(mockPrepare).toHaveBeenNthCalledWith(1, expect.stringMatching( /DELETE\s+FROM\s+\w+\s+WHERE\s+lock_key\s*=\s*\?\s+AND\s+expires_at\s*<\s*\?/ )) expect(mockStmt.run).toHaveBeenCalledWith('test-key', mockNow) // Then check for existence expect(mockPrepare).toHaveBeenNthCalledWith(2, expect.stringMatching( /SELECT\s+1\s+FROM\s+\w+\s+WHERE\s+lock_key\s*=\s*\?\s+AND\s+expires_at\s*>=\s*\?/ )) expect(mockStmt.get).toHaveBeenCalledWith('test-key', mockNow) }) it('should return false when lock does not exist', async () => { const mockStmt = { run: jest.fn(), get: jest.fn().mockReturnValue(undefined) // No result } mockPrepare.mockReturnValue(mockStmt) const result = await driver.exists('non-existent-key') expect(result).toBe(false) }) it('should clean up expired lock and return false', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockStmt = { run: jest.fn().mockReturnValue({ changes: 1 }), // Cleanup removed a lock get: jest.fn().mockReturnValue(undefined) // No lock exists after cleanup } mockPrepare.mockReturnValue(mockStmt) const result = await driver.exists('expired-key') expect(result).toBe(false) // Verify cleanup was called expect(mockStmt.run).toHaveBeenCalledWith('expired-key', mockNow) }) }) describe('getLockInfo', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should return lock info for existing lock', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockLockData = { value: 'test-value', expiresAt: mockNow + 30000, createdAt: mockNow - 5000 } const mockStmt = { get: jest.fn().mockReturnValue(mockLockData) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.getLockInfo('test-key') expect(result).toEqual(mockLockData) expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining( 'SELECT lock_value as value, expires_at as expiresAt, created_at as createdAt' )) expect(mockStmt.get).toHaveBeenCalledWith('test-key', mockNow) }) it('should return null when lock does not exist', async () => { const mockStmt = { get: jest.fn().mockReturnValue(undefined) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.getLockInfo('non-existent-key') expect(result).toBeNull() }) it('should return null when lock is expired', async () => { const mockStmt = { get: jest.fn().mockReturnValue(undefined) } mockPrepare.mockReturnValue(mockStmt) const result = await driver.getLockInfo('expired-key') expect(result).toBeNull() }) it('should include expires_at >= ? condition to filter expired locks', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockStmt = { get: jest.fn().mockReturnValue(null) } mockPrepare.mockReturnValue(mockStmt) await driver.getLockInfo('test-key') expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('expires_at >= ?')) expect(mockStmt.get).toHaveBeenCalledWith('test-key', mockNow) }) }) describe('cleanup', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should remove all expired locks', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) const mockStmt = { run: jest.fn().mockReturnValue({ changes: 5 }) } mockPrepare.mockReturnValue(mockStmt) await driver.cleanup() expect(mockPrepare).toHaveBeenCalledWith(expect.stringMatching( /DELETE\s+FROM\s+\w+\s+WHERE\s+expires_at\s*<\s*\?/ )) expect(mockStmt.run).toHaveBeenCalledWith(mockNow) }) it('should use custom table name', async () => { driver = new SQLiteLockDriver({ db: mockDb, tableName: 'custom_locks' }) jest.clearAllMocks() const mockStmt = { run: jest.fn() } mockPrepare.mockReturnValue(mockStmt) await driver.cleanup() expect(mockPrepare).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM custom_locks')) }) it('should handle case when no expired locks exist', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 0 }) } mockPrepare.mockReturnValue(mockStmt) await driver.cleanup() expect(mockStmt.run).toHaveBeenCalled() // Should not throw even when no changes }) }) describe('close', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) }) it('should close database connection when close method exists', async () => { await driver.close() expect(mockDb.close).toHaveBeenCalledTimes(1) }) it('should handle database without close method', async () => { const dbWithoutClose = { exec: jest.fn(), prepare: jest.fn().mockReturnValue({ run: jest.fn() }), transaction: jest.fn() } driver = new SQLiteLockDriver({ db: dbWithoutClose }) // Should not throw await driver.close() expect(dbWithoutClose).not.toHaveProperty('close') }) }) describe('SQL Injection Protection', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should use parameterized queries for all operations', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 1 }), get: jest.fn().mockReturnValue({ count: 0 }) } mockPrepare.mockReturnValue(mockStmt) // Test various operations with potentially malicious input const maliciousKey = "'; DROP TABLE atomic_locks; --" const maliciousValue = "'; DELETE FROM atomic_locks; --" await driver.tryAcquire(maliciousKey, maliciousValue, 30) await driver.release(maliciousKey, maliciousValue) await driver.exists(maliciousKey) await driver.getLockInfo(maliciousKey) // All calls should use parameterized queries (?) mockPrepare.mock.calls.forEach(call => { const sql = call[0] expect(sql).toContain('?') expect(sql).not.toContain(maliciousKey) expect(sql).not.toContain(maliciousValue) }) }) }) describe('Integration Scenarios', () => { beforeEach(() => { driver = new SQLiteLockDriver({ db: mockDb }) jest.clearAllMocks() }) it('should handle complete lock lifecycle', async () => { const mockStmt = { run: jest.fn().mockReturnValue({ changes: 1 }), get: jest.fn() } mockPrepare.mockReturnValue(mockStmt) // Mock different return values for different operations mockStmt.get .mockReturnValueOnce({ exists: true }) // exists() call .mockReturnValueOnce({ // getLockInfo() call value: 'test-value', expiresAt: Date.now() + 30000, createdAt: Date.now() }) // Acquire lock const acquired = await driver.tryAcquire('lifecycle-key', 'test-value', 60) expect(acquired).toBe(true) // Check existence const exists = await driver.exists('lifecycle-key') expect(exists).toBe(true) // Get lock info const info = await driver.getLockInfo('lifecycle-key') expect(info?.value).toBe('test-value') // Release lock const released = await driver.release('lifecycle-key', 'test-value') expect(released).toBe(true) }) it('should handle concurrent lock attempts', async () => { const mockStmt = { run: jest.fn() } mockPrepare.mockReturnValue(mockStmt) // First attempt succeeds mockStmt.run.mockReturnValueOnce({ changes: 0 }) // cleanup mockStmt.run.mockReturnValueOnce({ changes: 1 }) // insert succeeds const result1 = await driver.tryAcquire('concurrent-key', 'value1', 30) expect(result1).toBe(true) // Second attempt fails with constraint error const error = new Error('UNIQUE constraint failed') as any error.code = 'SQLITE_CONSTRAINT_PRIMARYKEY' mockStmt.run.mockReturnValueOnce({ changes: 0 }) // cleanup mockStmt.run.mockImplementationOnce(() => { throw error }) // insert fails const result2 = await driver.tryAcquire('concurrent-key', 'value2', 30) expect(result2).toBe(false) }) }) })