UNPKG

@shagital/atomic-lock

Version:

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

651 lines (482 loc) 21.3 kB
import { MemoryLockDriver } from '../src/drivers/memory-driver' describe('MemoryLockDriver', () => { let driver: MemoryLockDriver let mockSetInterval: jest.SpyInstance let mockClearInterval: jest.SpyInstance let consoleErrorSpy: jest.SpyInstance beforeEach(() => { jest.clearAllMocks() // Mock timer functions mockSetInterval = jest.spyOn(global, 'setInterval') mockClearInterval = jest.spyOn(global, 'clearInterval') consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() }) afterEach(() => { mockSetInterval.mockRestore() mockClearInterval.mockRestore() consoleErrorSpy.mockRestore() }) describe('Constructor', () => { it('should create driver with default config', () => { driver = new MemoryLockDriver() expect(driver).toBeDefined() expect(driver.getLockCount()).toBe(0) expect(mockSetInterval).not.toHaveBeenCalled() }) it('should create driver with empty config object', () => { driver = new MemoryLockDriver({}) expect(driver).toBeDefined() expect(driver.getLockCount()).toBe(0) expect(mockSetInterval).not.toHaveBeenCalled() }) it('should create driver with cleanup interval', () => { const mockTimerId = 'mock-timer-id' as any mockSetInterval.mockReturnValue(mockTimerId) driver = new MemoryLockDriver({ cleanupInterval: 30000 }) expect(driver).toBeDefined() expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 30000) }) it('should not setup cleanup interval when cleanupInterval is 0', () => { driver = new MemoryLockDriver({ cleanupInterval: 0 }) expect(mockSetInterval).not.toHaveBeenCalled() }) }) describe('tryAcquire', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should successfully acquire lock when key does not exist', async () => { const result = await driver.tryAcquire('test-key', 'lock-value-123', 30) expect(result).toBe(true) expect(driver.getLockCount()).toBe(1) }) it('should return false when lock already exists and is not expired', async () => { // First acquisition await driver.tryAcquire('test-key', 'first-value', 30) // Second acquisition should fail const result = await driver.tryAcquire('test-key', 'second-value', 30) expect(result).toBe(false) expect(driver.getLockCount()).toBe(1) }) it('should acquire lock when existing lock is expired', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) // First acquisition await driver.tryAcquire('test-key', 'first-value', 1) // 1 second expiry // Advance time past expiry jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) // 2 seconds later // Second acquisition should succeed const result = await driver.tryAcquire('test-key', 'second-value', 30) expect(result).toBe(true) expect(driver.getLockCount()).toBe(1) // Verify the lock has the new value const lockInfo = await driver.getLockInfo('test-key') expect(lockInfo?.value).toBe('second-value') }) it('should set correct expiry and creation times', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquire('test-key', 'lock-value', 10) const lockInfo = await driver.getLockInfo('test-key') expect(lockInfo?.expiresAt).toBe(mockNow + 10000) expect(lockInfo?.createdAt).toBe(mockNow) }) }) describe('tryAcquireMultiple', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should successfully acquire multiple locks when all keys are free', async () => { const result = await driver.tryAcquireMultiple( ['key1', 'key2', 'key3'], 'multi-lock-value', 15 ) expect(result).toBe(true) expect(driver.getLockCount()).toBe(3) // Verify all locks have the same value const lock1 = await driver.getLockInfo('key1') const lock2 = await driver.getLockInfo('key2') const lock3 = await driver.getLockInfo('key3') expect(lock1?.value).toBe('multi-lock-value') expect(lock2?.value).toBe('multi-lock-value') expect(lock3?.value).toBe('multi-lock-value') }) it('should return false when any key already has an active lock', async () => { // Pre-acquire one key await driver.tryAcquire('key2', 'existing-value', 30) const result = await driver.tryAcquireMultiple( ['key1', 'key2', 'key3'], 'multi-lock-value', 15 ) expect(result).toBe(false) expect(driver.getLockCount()).toBe(1) // Only the pre-existing lock // Verify no new locks were created expect(await driver.exists('key1')).toBe(false) expect(await driver.exists('key3')).toBe(false) }) it('should clean up expired locks before checking availability', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) // Create expired locks await driver.tryAcquire('key1', 'expired-value1', 1) await driver.tryAcquire('key2', 'expired-value2', 1) expect(driver.getLockCount()).toBe(2) // Advance time past expiry jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) // Try to acquire multiple - should clean up expired and succeed const result = await driver.tryAcquireMultiple( ['key1', 'key2', 'key3'], 'new-multi-value', 15 ) expect(result).toBe(true) expect(driver.getLockCount()).toBe(3) // Verify all locks have new values const lock1 = await driver.getLockInfo('key1') expect(lock1?.value).toBe('new-multi-value') }) it('should handle mixed expired and active locks', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) // Create one expired lock and one active lock await driver.tryAcquire('key1', 'expired-value', 1) // Will expire await driver.tryAcquire('key2', 'active-value', 3000) // Will remain active // Advance time to expire first lock but not second jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) const result = await driver.tryAcquireMultiple( ['key1', 'key2', 'key3'], 'new-multi-value', 15 ) expect(result).toBe(false) // Should fail due to active key2 expect(driver.getLockCount()).toBe(1) // Only key2 should remain // Verify key1 was cleaned up but key2 remains expect(await driver.exists('key1')).toBe(false) expect(await driver.exists('key2')).toBe(true) }) it('should handle empty keys array', async () => { const result = await driver.tryAcquireMultiple([], 'value', 10) expect(result).toBe(true) expect(driver.getLockCount()).toBe(0) }) it('should set same timestamp for all locks in multiple acquisition', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquireMultiple(['key1', 'key2'], 'multi-value', 10) const lock1 = await driver.getLockInfo('key1') const lock2 = await driver.getLockInfo('key2') expect(lock1?.createdAt).toBe(mockNow) expect(lock2?.createdAt).toBe(mockNow) expect(lock1?.expiresAt).toBe(mockNow + 10000) expect(lock2?.expiresAt).toBe(mockNow + 10000) }) }) describe('release', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should successfully release lock with correct value', async () => { await driver.tryAcquire('test-key', 'correct-value', 30) const result = await driver.release('test-key', 'correct-value') expect(result).toBe(true) expect(driver.getLockCount()).toBe(0) expect(await driver.exists('test-key')).toBe(false) }) it('should return false when lock value does not match', async () => { await driver.tryAcquire('test-key', 'correct-value', 30) const result = await driver.release('test-key', 'wrong-value') expect(result).toBe(false) expect(driver.getLockCount()).toBe(1) // Lock should still exist expect(await driver.exists('test-key')).toBe(true) }) it('should return false when lock does not exist', async () => { const result = await driver.release('non-existent-key', 'any-value') expect(result).toBe(false) expect(driver.getLockCount()).toBe(0) }) it('should return false when trying to release expired lock', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquire('test-key', 'value', 1) // Advance time past expiry jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) const result = await driver.release('test-key', 'value') expect(result).toBe(true) expect(driver.getLockCount()).toBe(0) }) }) describe('releaseMultiple', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should release all locks with matching values', async () => { await driver.tryAcquireMultiple(['key1', 'key2', 'key3'], 'multi-value', 30) const result = await driver.releaseMultiple(['key1', 'key2', 'key3'], 'multi-value') expect(result).toBe(3) expect(driver.getLockCount()).toBe(0) }) it('should return count of successfully released locks', async () => { await driver.tryAcquire('key1', 'correct-value', 30) await driver.tryAcquire('key2', 'wrong-value', 30) // key3 doesn't exist const result = await driver.releaseMultiple(['key1', 'key2', 'key3'], 'correct-value') expect(result).toBe(1) // Only key1 was released expect(driver.getLockCount()).toBe(1) // key2 still exists expect(await driver.exists('key2')).toBe(true) }) it('should handle empty keys array', async () => { const result = await driver.releaseMultiple([], 'any-value') expect(result).toBe(0) }) it('should handle mix of existing, non-existing, and wrong-value locks', async () => { await driver.tryAcquire('key1', 'correct-value', 30) await driver.tryAcquire('key2', 'wrong-value', 30) await driver.tryAcquire('key4', 'correct-value', 30) // key3 doesn't exist const result = await driver.releaseMultiple(['key1', 'key2', 'key3', 'key4'], 'correct-value') expect(result).toBe(2) // key1 and key4 were released expect(driver.getLockCount()).toBe(1) // Only key2 remains }) }) describe('exists', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should return true for existing non-expired lock', async () => { await driver.tryAcquire('test-key', 'value', 30) const result = await driver.exists('test-key') expect(result).toBe(true) }) it('should return false for non-existent lock', async () => { const result = await driver.exists('non-existent-key') expect(result).toBe(false) }) it('should return false and clean up expired lock', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquire('test-key', 'value', 1) expect(driver.getLockCount()).toBe(1) // Advance time past expiry jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) const result = await driver.exists('test-key') expect(result).toBe(false) expect(driver.getLockCount()).toBe(0) // Lock should be cleaned up }) }) describe('getLockInfo', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should return lock info for existing lock', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquire('test-key', 'test-value', 15) const result = await driver.getLockInfo('test-key') expect(result).toEqual({ key: 'test-key', value: 'test-value', expiresAt: mockNow + 15000, createdAt: mockNow, }) }) it('should return null for non-existent lock', async () => { const result = await driver.getLockInfo('non-existent-key') expect(result).toBeNull() }) it('should return null and clean up expired lock', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquire('test-key', 'value', 1) expect(driver.getLockCount()).toBe(1) // Advance time past expiry jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) const result = await driver.getLockInfo('test-key') expect(result).toBeNull() expect(driver.getLockCount()).toBe(0) // Lock should be cleaned up }) }) describe('cleanup', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should remove all expired locks', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) // Create locks with different expiry times await driver.tryAcquire('expired1', 'value1', 1) // Will expire in 1 second await driver.tryAcquire('expired2', 'value2', 2) // Will expire in 2 seconds await driver.tryAcquire('active', 'value3', 3600) // Will not expire expect(driver.getLockCount()).toBe(3) // Advance time to expire first two locks jest.spyOn(Date, 'now').mockReturnValue(mockNow + 3000) await driver.cleanup() expect(driver.getLockCount()).toBe(1) expect(await driver.exists('expired1')).toBe(false) expect(await driver.exists('expired2')).toBe(false) expect(await driver.exists('active')).toBe(true) }) it('should handle cleanup when no locks exist', async () => { await driver.cleanup() expect(driver.getLockCount()).toBe(0) }) it('should handle cleanup when no locks are expired', async () => { await driver.tryAcquire('active1', 'value1', 3600) await driver.tryAcquire('active2', 'value2', 3600) expect(driver.getLockCount()).toBe(2) await driver.cleanup() expect(driver.getLockCount()).toBe(2) // No locks should be removed }) }) describe('close', () => { it('should clear cleanup interval and all locks', async () => { const mockTimerId = 'mock-timer-id' as any mockSetInterval.mockReturnValue(mockTimerId) driver = new MemoryLockDriver({ cleanupInterval: 30000 }) // Add some locks await driver.tryAcquire('key1', 'value1', 30) await driver.tryAcquire('key2', 'value2', 30) expect(driver.getLockCount()).toBe(2) await driver.close() expect(mockClearInterval).toHaveBeenCalledWith(mockTimerId) expect(driver.getLockCount()).toBe(0) }) it('should only clear locks when no cleanup interval exists', async () => { driver = new MemoryLockDriver() await driver.tryAcquire('key1', 'value1', 30) expect(driver.getLockCount()).toBe(1) await driver.close() expect(mockClearInterval).not.toHaveBeenCalled() expect(driver.getLockCount()).toBe(0) }) }) describe('Testing Helper Methods', () => { beforeEach(() => { driver = new MemoryLockDriver() }) describe('getLockCount', () => { it('should return correct count of active locks', async () => { expect(driver.getLockCount()).toBe(0) await driver.tryAcquire('key1', 'value1', 30) expect(driver.getLockCount()).toBe(1) await driver.tryAcquire('key2', 'value2', 30) expect(driver.getLockCount()).toBe(2) await driver.release('key1', 'value1') expect(driver.getLockCount()).toBe(1) }) }) describe('getAllLocks', () => { it('should return all active locks', async () => { const mockNow = 1234567890000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquire('key1', 'value1', 10) await driver.tryAcquire('key2', 'value2', 20) const allLocks = driver.getAllLocks() expect(allLocks.size).toBe(2) expect(allLocks.get('key1')).toEqual({ key: 'key1', value: 'value1', expiresAt: mockNow + 10000, createdAt: mockNow, }) expect(allLocks.get('key2')).toEqual({ key: 'key2', value: 'value2', expiresAt: mockNow + 20000, createdAt: mockNow, }) }) it('should exclude expired locks from results', async () => { const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) await driver.tryAcquire('active', 'value1', 3600) // Active await driver.tryAcquire('expired', 'value2', 1) // Will expire expect(driver.getLockCount()).toBe(2) // Advance time to expire second lock jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) const allLocks = driver.getAllLocks() expect(allLocks.size).toBe(1) expect(allLocks.has('active')).toBe(true) expect(allLocks.has('expired')).toBe(false) }) it('should return empty map when no locks exist', () => { const allLocks = driver.getAllLocks() expect(allLocks.size).toBe(0) expect(allLocks).toBeInstanceOf(Map) }) }) }) describe('Cleanup Interval Integration', () => { it('should run cleanup on interval', async () => { let cleanupCallback: Function mockSetInterval.mockImplementation((callback) => { cleanupCallback = callback return 'timer-id' as any }) driver = new MemoryLockDriver({ cleanupInterval: 30000 }) const mockNow = 1000000 jest.spyOn(Date, 'now').mockReturnValue(mockNow) // Create expired and active locks await driver.tryAcquire('expired', 'value1', 1) await driver.tryAcquire('active', 'value2', 3600) expect(driver.getLockCount()).toBe(2) // Advance time to expire first lock jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) // Execute the cleanup callback await cleanupCallback!() expect(driver.getLockCount()).toBe(1) expect(await driver.exists('expired')).toBe(false) expect(await driver.exists('active')).toBe(true) }) it('should handle cleanup errors in interval', async () => { let cleanupCallback: Function mockSetInterval.mockImplementation((callback) => { cleanupCallback = callback return 'timer-id' as any }) driver = new MemoryLockDriver({ cleanupInterval: 30000 }) // Mock cleanup to throw error const originalCleanup = driver.cleanup jest.spyOn(driver, 'cleanup').mockRejectedValue(new Error('Cleanup error')) // Execute the cleanup callback - should not throw await cleanupCallback!() expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error)) // Restore original cleanup driver.cleanup = originalCleanup }) }) describe('Edge Cases', () => { beforeEach(() => { driver = new MemoryLockDriver() }) it('should handle concurrent operations correctly', async () => { // Simulate concurrent tryAcquire calls const promises = [ driver.tryAcquire('concurrent-key', 'value1', 30), driver.tryAcquire('concurrent-key', 'value2', 30), driver.tryAcquire('concurrent-key', 'value3', 30), ] const results = await Promise.all(promises) // Only one should succeed const successCount = results.filter((r) => r === true).length expect(successCount).toBe(1) expect(driver.getLockCount()).toBe(1) }) it('should handle zero expiry time', async () => { const result = await driver.tryAcquire('zero-expiry', 'value', 0) expect(result).toBe(true) // Lock exists but is immediately expired (implementation allows creation) const exists = await driver.exists('zero-expiry') expect(exists).toBe(true) // Changed from false to true expect(driver.getLockCount()).toBe(1) // Changed from 0 to 1 }) it('should handle negative expiry time', async () => { const result = await driver.tryAcquire('negative-expiry', 'value', -10) expect(result).toBe(true) // Lock should be immediately expired const exists = await driver.exists('negative-expiry') expect(exists).toBe(false) }) }) })