From 0f29ff8b78a61d2a6b3edfc4e653bc2ced4eee23 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Tue, 28 Oct 2025 11:55:43 -0700 Subject: [PATCH] test: add comprehensive integration tests for RedisLockService Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../RedisLockService.integration.test.ts | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 src/shared/infrastructure/locking/tests/RedisLockService.integration.test.ts diff --git a/src/shared/infrastructure/locking/tests/RedisLockService.integration.test.ts b/src/shared/infrastructure/locking/tests/RedisLockService.integration.test.ts new file mode 100644 index 00000000..262e9221 --- /dev/null +++ b/src/shared/infrastructure/locking/tests/RedisLockService.integration.test.ts @@ -0,0 +1,331 @@ +import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis'; +import Redis from 'ioredis'; +import { RedisLockService } from '../RedisLockService'; + +describe('RedisLockService Integration', () => { + let redisContainer: StartedRedisContainer; + let redis: Redis; + let lockService: RedisLockService; + + beforeAll(async () => { + // Start Redis container + redisContainer = await new RedisContainer('redis:7-alpine') + .withExposedPorts(6379) + .start(); + + // Create Redis connection + const connectionUrl = redisContainer.getConnectionUrl(); + redis = new Redis(connectionUrl, { maxRetriesPerRequest: null }); + + // Create lock service + lockService = new RedisLockService(redis); + }, 60000); // Increase timeout for container startup + + afterAll(async () => { + // Clean up + if (redis) { + await redis.quit(); + } + if (redisContainer) { + await redisContainer.stop(); + } + }); + + beforeEach(async () => { + // Clear Redis data between tests + await redis.flushall(); + }); + + describe('Basic Lock Operations', () => { + it('should acquire and release a lock successfully', async () => { + // Arrange + const lockKey = 'test-lock-key'; + let executionCount = 0; + const testFunction = async () => { + executionCount++; + return 'success'; + }; + + // Act + const requestLock = lockService.createRequestLock(); + const result = await requestLock(lockKey, testFunction); + + // Assert + expect(result).toBe('success'); + expect(executionCount).toBe(1); + + // Verify lock was released by checking Redis directly + const lockPattern = `oauth:lock:*:${lockKey}`; + const keys = await redis.keys(lockPattern); + expect(keys).toHaveLength(0); + }); + + it('should prevent concurrent execution of the same lock key', async () => { + // Arrange + const lockKey = 'concurrent-test-lock'; + let executionOrder: number[] = []; + let currentExecution = 0; + + const createTestFunction = (id: number) => async () => { + const executionId = ++currentExecution; + executionOrder.push(id); + + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 100)); + + return `result-${id}-${executionId}`; + }; + + // Act - Start two concurrent operations with same lock key + const requestLock = lockService.createRequestLock(); + const [result1, result2] = await Promise.all([ + requestLock(lockKey, createTestFunction(1)), + requestLock(lockKey, createTestFunction(2)), + ]); + + // Assert - Both should succeed but execute sequentially + expect(result1).toMatch(/^result-1-\d+$/); + expect(result2).toMatch(/^result-2-\d+$/); + expect(executionOrder).toHaveLength(2); + + // Verify they executed sequentially (not concurrently) + expect(currentExecution).toBe(2); + }); + + it('should allow concurrent execution with different lock keys', async () => { + // Arrange + const lockKey1 = 'lock-key-1'; + const lockKey2 = 'lock-key-2'; + let startTimes: number[] = []; + + const createTestFunction = (id: number) => async () => { + startTimes.push(Date.now()); + await new Promise(resolve => setTimeout(resolve, 200)); + return `result-${id}`; + }; + + // Act - Start concurrent operations with different lock keys + const requestLock = lockService.createRequestLock(); + const startTime = Date.now(); + const [result1, result2] = await Promise.all([ + requestLock(lockKey1, createTestFunction(1)), + requestLock(lockKey2, createTestFunction(2)), + ]); + const totalTime = Date.now() - startTime; + + // Assert - Both should succeed and execute concurrently + expect(result1).toBe('result-1'); + expect(result2).toBe('result-2'); + expect(startTimes).toHaveLength(2); + + // Should complete in roughly 200ms (concurrent) rather than 400ms (sequential) + expect(totalTime).toBeLessThan(350); + + // Start times should be close together (concurrent execution) + const timeDiff = Math.abs(startTimes[1]! - startTimes[0]!); + expect(timeDiff).toBeLessThan(50); + }); + + it('should handle function that throws an error', async () => { + // Arrange + const lockKey = 'error-test-lock'; + const errorMessage = 'Test error'; + const errorFunction = async () => { + throw new Error(errorMessage); + }; + + // Act & Assert + const requestLock = lockService.createRequestLock(); + await expect(requestLock(lockKey, errorFunction)).rejects.toThrow(errorMessage); + + // Verify lock was released even after error + const lockPattern = `oauth:lock:*:${lockKey}`; + const keys = await redis.keys(lockPattern); + expect(keys).toHaveLength(0); + }); + + it('should handle async function that returns a promise', async () => { + // Arrange + const lockKey = 'async-test-lock'; + const asyncFunction = async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + return { data: 'async-result', timestamp: Date.now() }; + }; + + // Act + const requestLock = lockService.createRequestLock(); + const result = await requestLock(lockKey, asyncFunction); + + // Assert + expect(result).toHaveProperty('data', 'async-result'); + expect(result).toHaveProperty('timestamp'); + expect(typeof result.timestamp).toBe('number'); + }); + }); + + describe('Lock Key Isolation', () => { + it('should include Fly.io instance ID in lock key when available', async () => { + // Arrange + const originalAllocId = process.env.FLY_ALLOC_ID; + process.env.FLY_ALLOC_ID = 'test-instance-123'; + + const lockKey = 'instance-test-lock'; + let lockKeyUsed = ''; + + // Mock redlock to capture the actual lock key used + const originalAcquire = lockService['redlock'].acquire; + lockService['redlock'].acquire = jest.fn().mockImplementation(async (keys: string[]) => { + lockKeyUsed = keys[0]!; + return originalAcquire.call(lockService['redlock'], keys, 30000); + }); + + try { + // Act + const requestLock = lockService.createRequestLock(); + await requestLock(lockKey, async () => 'test'); + + // Assert + expect(lockKeyUsed).toBe(`oauth:lock:test-instance-123:${lockKey}`); + } finally { + // Cleanup + process.env.FLY_ALLOC_ID = originalAllocId; + lockService['redlock'].acquire = originalAcquire; + } + }); + + it('should use "local" as default instance ID when FLY_ALLOC_ID is not set', async () => { + // Arrange + const originalAllocId = process.env.FLY_ALLOC_ID; + delete process.env.FLY_ALLOC_ID; + + const lockKey = 'local-test-lock'; + let lockKeyUsed = ''; + + // Mock redlock to capture the actual lock key used + const originalAcquire = lockService['redlock'].acquire; + lockService['redlock'].acquire = jest.fn().mockImplementation(async (keys: string[]) => { + lockKeyUsed = keys[0]!; + return originalAcquire.call(lockService['redlock'], keys, 30000); + }); + + try { + // Act + const requestLock = lockService.createRequestLock(); + await requestLock(lockKey, async () => 'test'); + + // Assert + expect(lockKeyUsed).toBe(`oauth:lock:local:${lockKey}`); + } finally { + // Cleanup + process.env.FLY_ALLOC_ID = originalAllocId; + lockService['redlock'].acquire = originalAcquire; + } + }); + }); + + describe('Lock Timeout and TTL', () => { + it('should automatically release lock after TTL expires', async () => { + // Arrange + const lockKey = 'ttl-test-lock'; + + // Manually acquire a lock with short TTL to simulate timeout + const instanceId = process.env.FLY_ALLOC_ID || 'local'; + const fullLockKey = `oauth:lock:${instanceId}:${lockKey}`; + + // Use redlock directly to set a very short TTL (100ms) + const shortLock = await lockService['redlock'].acquire([fullLockKey], 100); + + // Act - Wait for lock to expire + await new Promise(resolve => setTimeout(resolve, 200)); + + // Try to acquire the same lock - should succeed if previous lock expired + const requestLock = lockService.createRequestLock(); + const result = await requestLock(lockKey, async () => 'success-after-timeout'); + + // Assert + expect(result).toBe('success-after-timeout'); + + // Cleanup - release the short lock (may already be expired) + try { + await lockService['redlock'].release(shortLock); + } catch { + // Ignore errors if lock already expired + } + }); + + it('should handle high concurrency with retry mechanism', async () => { + // Arrange + const lockKey = 'high-concurrency-lock'; + const concurrentOperations = 5; + let completedOperations = 0; + + const testFunction = async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + return ++completedOperations; + }; + + // Act - Start multiple concurrent operations + const requestLock = lockService.createRequestLock(); + const promises = Array.from({ length: concurrentOperations }, () => + requestLock(lockKey, testFunction) + ); + + const results = await Promise.all(promises); + + // Assert - All operations should complete successfully + expect(results).toHaveLength(concurrentOperations); + expect(completedOperations).toBe(concurrentOperations); + + // Results should be sequential numbers (1, 2, 3, 4, 5) + const sortedResults = results.sort((a, b) => a - b); + expect(sortedResults).toEqual([1, 2, 3, 4, 5]); + }); + }); + + describe('Error Handling', () => { + it('should handle Redis connection issues gracefully', async () => { + // Arrange - Create a new Redis connection that we can close + const testRedis = new Redis(redisContainer.getConnectionUrl(), { + maxRetriesPerRequest: null + }); + const testLockService = new RedisLockService(testRedis); + + // Close the connection to simulate network issues + await testRedis.quit(); + + // Act & Assert - Should throw an error when trying to acquire lock + const requestLock = testLockService.createRequestLock(); + await expect( + requestLock('test-key', async () => 'should-not-execute') + ).rejects.toThrow(); + }); + + it('should release lock even when function execution is interrupted', async () => { + // Arrange + const lockKey = 'interrupt-test-lock'; + let lockAcquired = false; + + const interruptedFunction = async () => { + lockAcquired = true; + // Simulate an interruption/error after lock is acquired + throw new Error('Simulated interruption'); + }; + + // Act & Assert + const requestLock = lockService.createRequestLock(); + await expect(requestLock(lockKey, interruptedFunction)).rejects.toThrow('Simulated interruption'); + + // Verify lock was acquired initially + expect(lockAcquired).toBe(true); + + // Verify lock was released after error + const lockPattern = `oauth:lock:*:${lockKey}`; + const keys = await redis.keys(lockPattern); + expect(keys).toHaveLength(0); + + // Verify we can acquire the same lock again + const result = await requestLock(lockKey, async () => 'recovered'); + expect(result).toBe('recovered'); + }); + }); +}); -- 2.51.2