diff --git a/src/__tests__/Signal/lid-mapping.test.ts b/src/__tests__/Signal/lid-mapping.test.ts index 762829af..04e81645 100644 --- a/src/__tests__/Signal/lid-mapping.test.ts +++ b/src/__tests__/Signal/lid-mapping.test.ts @@ -82,4 +82,219 @@ describe('LIDMappingStore', () => { ) }) }) + + // ======================================================================== + // M3: REQUEST COALESCING TESTS + // ======================================================================== + + describe('Request Coalescing (M3)', () => { + it('should deduplicate concurrent getLIDForPN calls for same PN', async () => { + const pn = '12345@s.whatsapp.net' + const lidUser = 'aaaaa' + + // @ts-ignore - Mock DB lookup to return mapping + mockKeys.get.mockResolvedValue({ '12345': lidUser } as SignalDataTypeMap['lid-mapping']) + + // Make 10 concurrent calls for the same PN + const promises = Array(10).fill(null).map(() => lidMappingStore.getLIDForPN(pn)) + + // All should resolve to same result + const results = await Promise.all(promises) + expect(results.every(r => r === `${lidUser}@lid`)).toBe(true) + + // But DB should only be queried ONCE (not 10 times) + // The batch method getLIDsForPNs calls keys.get once + expect(mockKeys.get).toHaveBeenCalledTimes(1) + }) + + it('should deduplicate concurrent getPNForLID calls for same LID', async () => { + const lid = '54321@lid' + const pnUser = 'bbbbb' + + // @ts-ignore - Mock DB lookup to return reverse mapping + mockKeys.get.mockResolvedValue({ '54321_reverse': pnUser } as SignalDataTypeMap['lid-mapping']) + + // Make 10 concurrent calls for the same LID + const promises = Array(10).fill(null).map(() => lidMappingStore.getPNForLID(lid)) + + // All should resolve to same result + const results = await Promise.all(promises) + expect(results.every(r => r === `${pnUser}@s.whatsapp.net`)).toBe(true) + + // But DB should only be queried ONCE (not 10 times) + expect(mockKeys.get).toHaveBeenCalledTimes(1) + }) + + it('should NOT coalesce calls for different PNs', async () => { + const pn1 = '11111@s.whatsapp.net' + const pn2 = '22222@s.whatsapp.net' + + // @ts-ignore - Mock to return different mappings + mockKeys.get.mockResolvedValue({ '11111': 'aaaaa', '22222': 'bbbbb' } as SignalDataTypeMap['lid-mapping']) + + // Concurrent calls for DIFFERENT PNs + const [result1, result2] = await Promise.all([ + lidMappingStore.getLIDForPN(pn1), + lidMappingStore.getLIDForPN(pn2) + ]) + + expect(result1).toBe('aaaaa@lid') + expect(result2).toBe('bbbbb@lid') + + // Should make 2 separate DB calls (no coalescing) + expect(mockKeys.get).toHaveBeenCalledTimes(2) + }) + }) + + // ======================================================================== + // V4: DESTROYED FLAG & OPERATION COUNTER TESTS + // ======================================================================== + + describe('Destroyed Flag Protection (V4, M2)', () => { + it('should reject operations after destroy()', async () => { + lidMappingStore.destroy() + + // All operations should throw after destroy + await expect(lidMappingStore.getLIDForPN('12345@s.whatsapp.net')) + .rejects.toThrow('LIDMappingStore has been destroyed') + + await expect(lidMappingStore.getPNForLID('54321@lid')) + .rejects.toThrow('LIDMappingStore has been destroyed') + + await expect(lidMappingStore.storeLIDPNMappings([{ lid: 'a@lid', pn: 'b@s.whatsapp.net' }])) + .rejects.toThrow('LIDMappingStore has been destroyed') + }) + + it('should allow destroy() to be called multiple times safely', () => { + // First destroy + lidMappingStore.destroy() + + // Second destroy should not throw (reentrancy guard) + expect(() => lidMappingStore.destroy()).not.toThrow() + + // Third destroy should also be safe + expect(() => lidMappingStore.destroy()).not.toThrow() + }) + + it('should complete active operations before destroying resources (graceful degradation)', async () => { + const pn = '12345@s.whatsapp.net' + + // Mock slow DB operation (simulates long-running operation) + let operationStarted = false + let operationCompleted = false + + // @ts-ignore + mockKeys.get.mockImplementation(async () => { + operationStarted = true + await new Promise(resolve => setTimeout(resolve, 100)) // 100ms delay + operationCompleted = true + return { '12345': 'aaaaa' } as SignalDataTypeMap['lid-mapping'] + }) + + // Start operation + const operationPromise = lidMappingStore.getLIDForPN(pn) + + // Wait a bit to ensure operation has started + await new Promise(resolve => setTimeout(resolve, 10)) + expect(operationStarted).toBe(true) + expect(operationCompleted).toBe(false) + + // Call destroy while operation is in progress + lidMappingStore.destroy() + + // Operation should still complete successfully (graceful degradation) + const result = await operationPromise + expect(result).toBe('aaaaa@lid') + expect(operationCompleted).toBe(true) + + // But new operations should be rejected + await expect(lidMappingStore.getLIDForPN(pn)) + .rejects.toThrow('LIDMappingStore has been destroyed') + }) + }) + + // ======================================================================== + // CACHE & OPTIMIZATION TESTS + // ======================================================================== + + describe('Cache Behavior', () => { + it('should use cache for subsequent lookups (no DB hit)', async () => { + const pn = '12345@s.whatsapp.net' + const lidUser = 'aaaaa' + + // @ts-ignore - First lookup hits DB + mockKeys.get.mockResolvedValue({ '12345': lidUser } as SignalDataTypeMap['lid-mapping']) + + // First lookup - cache miss, DB hit + const result1 = await lidMappingStore.getLIDForPN(pn) + expect(result1).toBe(`${lidUser}@lid`) + expect(mockKeys.get).toHaveBeenCalledTimes(1) + + // Second lookup - cache hit, no DB call + const result2 = await lidMappingStore.getLIDForPN(pn) + expect(result2).toBe(`${lidUser}@lid`) + + // DB should still only have been called once (cache hit) + expect(mockKeys.get).toHaveBeenCalledTimes(1) + }) + + it('should clear cache on destroy()', async () => { + const pn = '12345@s.whatsapp.net' + + // @ts-ignore + mockKeys.get.mockResolvedValue({ '12345': 'aaaaa' } as SignalDataTypeMap['lid-mapping']) + + // Populate cache + await lidMappingStore.getLIDForPN(pn) + + // Destroy should clear cache + lidMappingStore.destroy() + + // Create new store + const newStore = new LIDMappingStore(mockKeys, logger, mockPnToLIDFunc) + + // New lookup should hit DB again (cache was cleared) + jest.clearAllMocks() // Reset call count + await newStore.getLIDForPN(pn) + expect(mockKeys.get).toHaveBeenCalledTimes(1) + }) + }) + + // ======================================================================== + // EDGE CASES & ERROR HANDLING + // ======================================================================== + + describe('Edge Cases', () => { + it('should handle invalid JIDs gracefully', async () => { + const result1 = await lidMappingStore.getLIDForPN('invalid') + expect(result1).toBeNull() + + const result2 = await lidMappingStore.getPNForLID('invalid') + expect(result2).toBeNull() + }) + + it('should handle empty results from DB', async () => { + // @ts-ignore + mockKeys.get.mockResolvedValue({} as SignalDataTypeMap['lid-mapping']) + + const result = await lidMappingStore.getLIDForPN('12345@s.whatsapp.net') + expect(result).toBeNull() + }) + + it('should handle batch operations with mixed valid/invalid JIDs', async () => { + // @ts-ignore + mockKeys.get.mockResolvedValue({ '12345': 'aaaaa' } as SignalDataTypeMap['lid-mapping']) + + const result = await lidMappingStore.getLIDsForPNs([ + '12345@s.whatsapp.net', // Valid + 'invalid', // Invalid + '67890@s.whatsapp.net' // Valid but not in DB + ]) + + // Should only return valid results + expect(result).toEqual([ + { pn: '12345@s.whatsapp.net', lid: 'aaaaa@lid' } + ]) + }) + }) })