import NodeCache from '@cacheable/node-cache' import { jest } from '@jest/globals' import P from 'pino' import { handleIdentityChange, type IdentityChangeContext, type IdentityChangeResult } from '../../Utils/identity-change-handler' import { type BinaryNode } from '../../WABinary' const logger = P({ level: 'silent' }) type ValidateSessionFn = (jid: string) => Promise<{ exists: boolean; reason?: string }> type AssertSessionsFn = (jids: string[], force?: boolean) => Promise describe('Identity Change Handling', () => { let mockValidateSession: jest.Mock let mockAssertSessions: jest.Mock let identityAssertDebounce: NodeCache let mockMeId: string | undefined let mockMeLid: string | undefined function createIdentityChangeNode(from: string, offline?: string): BinaryNode { return { tag: 'notification', attrs: { from, type: 'encrypt', ...(offline !== undefined ? { offline } : {}) }, content: [ { tag: 'identity', attrs: {}, content: Buffer.from('test-identity-key') } ] } } function createContext(): IdentityChangeContext { return { meId: mockMeId, meLid: mockMeLid, validateSession: mockValidateSession, assertSessions: mockAssertSessions, debounceCache: identityAssertDebounce, logger } } beforeEach(() => { jest.clearAllMocks() mockValidateSession = jest.fn() mockAssertSessions = jest.fn() identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false }) mockMeId = 'myuser@s.whatsapp.net' mockMeLid = 'mylid@lid' }) describe('Core Checks', () => { it('should skip companion devices (device > 0)', async () => { const node = createIdentityChangeNode('user:5@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) expect(mockValidateSession).not.toHaveBeenCalled() expect(result.action).toBe('skipped_companion_device') }) it('should process primary device (device 0 or undefined)', async () => { mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('user@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) expect(result.action).toBe('session_refreshed') expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true) }) it('should skip self-primary identity (PN match)', async () => { const node = createIdentityChangeNode('myuser@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) expect(mockValidateSession).not.toHaveBeenCalled() expect(result.action).toBe('skipped_self_primary') }) it('should skip self-primary identity (LID match)', async () => { const node = createIdentityChangeNode('mylid@lid') const result = await handleIdentityChange(node, createContext()) expect(mockValidateSession).not.toHaveBeenCalled() expect(result.action).toBe('skipped_self_primary') }) it('should skip self-primary identity when only meLid exists', async () => { // FIX: Test for the case where meId is undefined but meLid matches mockMeId = undefined mockMeLid = 'mylid@lid' const node = createIdentityChangeNode('mylid@lid') const result = await handleIdentityChange(node, createContext()) expect(mockValidateSession).not.toHaveBeenCalled() expect(result.action).toBe('skipped_self_primary') }) it('should create session when no existing session exists', async () => { // FIX: Identity change is the signal to rebuild session, even if none exists // This is critical for key reset or device restore scenarios mockValidateSession.mockResolvedValue({ exists: false }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('user@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true) expect(result.action).toBe('session_refreshed') expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(false) }) it('should skip session refresh during offline processing', async () => { const node = createIdentityChangeNode('user@s.whatsapp.net', '0') const result = await handleIdentityChange(node, createContext()) // FIX: validateSession should not be called for offline notifications // because we skip before reaching that point expect(mockAssertSessions).not.toHaveBeenCalled() expect(result.action).toBe('skipped_offline') }) it('should refresh session when online with existing session', async () => { mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('user@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true) expect(result.action).toBe('session_refreshed') expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true) }) }) describe('Debounce', () => { it('should debounce multiple identity changes for the same JID', async () => { mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('user@s.whatsapp.net') const result1 = await handleIdentityChange(node, createContext()) expect(result1.action).toBe('session_refreshed') const result2 = await handleIdentityChange(node, createContext()) expect(result2.action).toBe('debounced') expect(mockAssertSessions).toHaveBeenCalledTimes(1) }) it('should allow different JIDs to process independently', async () => { mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const result1 = await handleIdentityChange(createIdentityChangeNode('user1@s.whatsapp.net'), createContext()) const result2 = await handleIdentityChange(createIdentityChangeNode('user2@s.whatsapp.net'), createContext()) expect(result1.action).toBe('session_refreshed') expect(result2.action).toBe('session_refreshed') expect(mockAssertSessions).toHaveBeenCalledTimes(2) }) it('should NOT set debounce cache when skipping due to offline', async () => { // FIX: Debounce should only be set when we actually attempt refresh const node = createIdentityChangeNode('user@s.whatsapp.net', '0') const result1 = await handleIdentityChange(node, createContext()) expect(result1.action).toBe('skipped_offline') // Now process the same JID online - it should NOT be debounced mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const onlineNode = createIdentityChangeNode('user@s.whatsapp.net') const result2 = await handleIdentityChange(onlineNode, createContext()) expect(result2.action).toBe('session_refreshed') expect(mockAssertSessions).toHaveBeenCalledTimes(1) }) }) describe('Error Handling', () => { it('should handle assertSessions failure gracefully', async () => { mockValidateSession.mockResolvedValue({ exists: true }) const testError = new Error('Session assertion failed') mockAssertSessions.mockRejectedValue(testError) const node = createIdentityChangeNode('user@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) expect(result.action).toBe('session_refresh_failed') expect((result as { error: unknown }).error).toBe(testError) }) it('should propagate validateSession errors', async () => { mockValidateSession.mockRejectedValue(new Error('Database error')) const node = createIdentityChangeNode('user@s.whatsapp.net') await expect(handleIdentityChange(node, createContext())).rejects.toThrow('Database error') }) }) describe('Edge Cases', () => { it('should return invalid_notification when from is missing', async () => { const node: BinaryNode = { tag: 'notification', attrs: { type: 'encrypt' }, content: [{ tag: 'identity', attrs: {}, content: Buffer.from('key') }] } const result = await handleIdentityChange(node, createContext()) expect(result.action).toBe('invalid_notification') }) it('should return no_identity_node when identity child is missing', async () => { const node: BinaryNode = { tag: 'notification', attrs: { from: 'user@s.whatsapp.net', type: 'encrypt' }, content: [] } const result = await handleIdentityChange(node, createContext()) expect(result.action).toBe('no_identity_node') }) it('should handle LID JIDs correctly', async () => { mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('12345@lid') const result = await handleIdentityChange(node, createContext()) expect(mockValidateSession).toHaveBeenCalledWith('12345@lid') expect(result.action).toBe('session_refreshed') }) it('should process when both meId and meLid are undefined', async () => { mockMeId = undefined mockMeLid = undefined mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('anyuser@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) expect(result.action).toBe('session_refreshed') }) }) describe('Result Types', () => { it('should include hadExistingSession in session_refreshed result', async () => { mockValidateSession.mockResolvedValue({ exists: true }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('user@s.whatsapp.net') const result = (await handleIdentityChange(node, createContext())) as Extract< IdentityChangeResult, { action: 'session_refreshed' } > expect(result.action).toBe('session_refreshed') expect(result.hadExistingSession).toBe(true) }) it('should return hadExistingSession=false when creating new session', async () => { mockValidateSession.mockResolvedValue({ exists: false }) mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('user@s.whatsapp.net') const result = (await handleIdentityChange(node, createContext())) as Extract< IdentityChangeResult, { action: 'session_refreshed' } > expect(result.action).toBe('session_refreshed') expect(result.hadExistingSession).toBe(false) }) it('should include device number in skipped_companion_device result', async () => { const node = createIdentityChangeNode('user:5@s.whatsapp.net') const result = (await handleIdentityChange(node, createContext())) as Extract< IdentityChangeResult, { action: 'skipped_companion_device' } > expect(result.action).toBe('skipped_companion_device') expect(result.device).toBe(5) }) }) })