fix: address PR review comments for identity change handler
Fixes 4 issues identified in PR #35 code review: ## 1. Self-Primary Identity Check (Copilot #1) - BEFORE: `ctx.meId && (areJidsSameUser(...) || (ctx.meLid && ...))` - AFTER: Check meId and meLid independently with OR - FIX: Now correctly detects when only meLid exists ## 2. Debounce Cache Placement (Copilot #3, #4) - BEFORE: Cache set immediately after debounce check - AFTER: Cache set only before actual assertSessions call - FIX: Prevents incorrect debouncing when exiting early (offline, etc.) ## 3. Session Regression (ChatGPT Codex P2) - BEFORE: Returned 'skipped_no_session' when no session exists - AFTER: Always call assertSessions - identity change IS the signal to rebuild - FIX: Critical for key reset and device restore scenarios ## 4. Result Type Enhancement - Added `hadExistingSession: boolean` to 'session_refreshed' result - Removed 'skipped_no_session' action (no longer applicable) - Enables better monitoring of session creation vs refresh ## Test Updates - Added test for meLid-only self-primary detection - Updated session creation test (no longer skips) - Added test verifying debounce not set on offline skip - Added Result Types test suite for type safety
This commit is contained in:
@@ -37,8 +37,7 @@ export type IdentityChangeResult =
|
||||
| { action: 'skipped_self_primary' }
|
||||
| { action: 'debounced' }
|
||||
| { action: 'skipped_offline' }
|
||||
| { action: 'skipped_no_session' }
|
||||
| { action: 'session_refreshed' }
|
||||
| { action: 'session_refreshed'; hadExistingSession: boolean }
|
||||
| { action: 'session_refresh_failed'; error: unknown }
|
||||
|
||||
/**
|
||||
@@ -74,9 +73,12 @@ export type IdentityChangeContext = {
|
||||
* as they don't require session refresh
|
||||
* 3. **Self-Primary Skip**: Skips notifications for the current user's own identity
|
||||
* 4. **Debouncing**: Prevents duplicate processing for the same JID within TTL window
|
||||
* 5. **Session Check**: Verifies an existing session exists before attempting refresh
|
||||
* 6. **Offline Handling**: Skips refresh during offline notification processing
|
||||
* 7. **Session Refresh**: Attempts to refresh the session with force flag
|
||||
* 5. **Offline Handling**: Skips refresh during offline notification processing
|
||||
* 6. **Session Refresh**: Attempts to refresh/create the session with force flag
|
||||
*
|
||||
* **Important**: Identity change notifications signal that we need to rebuild the session,
|
||||
* regardless of whether an existing session exists. This is critical for cases where
|
||||
* the local session was cleared (e.g., after key reset or device restore).
|
||||
*
|
||||
* @param node - The binary node containing the identity change notification
|
||||
* @param ctx - Context object with required dependencies
|
||||
@@ -95,7 +97,7 @@ export type IdentityChangeContext = {
|
||||
*
|
||||
* switch (result.action) {
|
||||
* case 'session_refreshed':
|
||||
* console.log('Session successfully refreshed')
|
||||
* console.log('Session refreshed, had existing:', result.hadExistingSession)
|
||||
* break
|
||||
* case 'session_refresh_failed':
|
||||
* console.error('Failed to refresh session:', result.error)
|
||||
@@ -132,45 +134,49 @@ export async function handleIdentityChange(
|
||||
}
|
||||
|
||||
// Skip self-primary identity changes
|
||||
const isSelfPrimary = ctx.meId && (
|
||||
areJidsSameUser(from, ctx.meId) ||
|
||||
(ctx.meLid && areJidsSameUser(from, ctx.meLid))
|
||||
)
|
||||
if (isSelfPrimary) {
|
||||
// FIX: Check meId and meLid independently to handle cases where only meLid exists
|
||||
const matchesMeId = ctx.meId && areJidsSameUser(from, ctx.meId)
|
||||
const matchesMeLid = ctx.meLid && areJidsSameUser(from, ctx.meLid)
|
||||
if (matchesMeId || matchesMeLid) {
|
||||
ctx.logger.info({ jid: from }, 'self primary identity changed')
|
||||
return { action: 'skipped_self_primary' }
|
||||
}
|
||||
|
||||
// Debounce to prevent duplicate processing
|
||||
// Check early but don't set yet - we only want to debounce actual refresh attempts
|
||||
if (ctx.debounceCache.get(from)) {
|
||||
ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)')
|
||||
return { action: 'debounced' }
|
||||
}
|
||||
|
||||
// Mark as processing in debounce cache
|
||||
ctx.debounceCache.set(from, true)
|
||||
|
||||
// Check if we have an existing session to refresh
|
||||
const hasExistingSession = await ctx.validateSession(from)
|
||||
if (!hasExistingSession.exists) {
|
||||
ctx.logger.debug({ jid: from }, 'no old session, skipping session refresh')
|
||||
return { action: 'skipped_no_session' }
|
||||
}
|
||||
|
||||
ctx.logger.debug({ jid: from }, 'old session exists, will refresh session')
|
||||
|
||||
// Skip refresh during offline notification processing
|
||||
// Offline notifications are processed in batch and shouldn't trigger immediate refreshes
|
||||
// FIX: Check this BEFORE setting debounce cache to avoid incorrect debouncing
|
||||
const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline)
|
||||
if (isOfflineNotification) {
|
||||
ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing')
|
||||
return { action: 'skipped_offline' }
|
||||
}
|
||||
|
||||
// Attempt session refresh
|
||||
// Check if we have an existing session (for logging purposes only)
|
||||
// FIX: We no longer skip refresh when no session exists - identity change is the
|
||||
// signal to rebuild the session, which is critical after key reset or device restore
|
||||
const hasExistingSession = await ctx.validateSession(from)
|
||||
|
||||
if (hasExistingSession.exists) {
|
||||
ctx.logger.debug({ jid: from }, 'existing session found, will refresh')
|
||||
} else {
|
||||
ctx.logger.debug({ jid: from }, 'no existing session, will create new session')
|
||||
}
|
||||
|
||||
// FIX: Set debounce cache only immediately before the actual refresh attempt
|
||||
// This ensures we don't incorrectly debounce when we exit early (offline, etc.)
|
||||
ctx.debounceCache.set(from, true)
|
||||
|
||||
// Attempt session refresh/creation
|
||||
try {
|
||||
await ctx.assertSessions([from], true)
|
||||
return { action: 'session_refreshed' }
|
||||
return { action: 'session_refreshed', hadExistingSession: hasExistingSession.exists }
|
||||
} catch (error) {
|
||||
ctx.logger.warn(
|
||||
{ error, jid: from },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import NodeCache from '@cacheable/node-cache'
|
||||
import { jest } from '@jest/globals'
|
||||
import P from 'pino'
|
||||
import { handleIdentityChange, type IdentityChangeContext } from '../../Utils/identity-change-handler'
|
||||
import { handleIdentityChange, type IdentityChangeContext, type IdentityChangeResult } from '../../Utils/identity-change-handler'
|
||||
import { type BinaryNode } from '../../WABinary'
|
||||
|
||||
const logger = P({ level: 'silent' })
|
||||
@@ -13,7 +13,7 @@ describe('Identity Change Handling', () => {
|
||||
let mockValidateSession: jest.Mock<ValidateSessionFn>
|
||||
let mockAssertSessions: jest.Mock<AssertSessionsFn>
|
||||
let identityAssertDebounce: NodeCache<boolean>
|
||||
let mockMeId: string
|
||||
let mockMeId: string | undefined
|
||||
let mockMeLid: string | undefined
|
||||
|
||||
function createIdentityChangeNode(from: string, offline?: string): BinaryNode {
|
||||
@@ -71,6 +71,7 @@ describe('Identity Change Handling', () => {
|
||||
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 () => {
|
||||
@@ -89,22 +90,38 @@ describe('Identity Change Handling', () => {
|
||||
expect(result.action).toBe('skipped_self_primary')
|
||||
})
|
||||
|
||||
it('should skip when no existing session', async () => {
|
||||
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).not.toHaveBeenCalled()
|
||||
expect(result.action).toBe('skipped_no_session')
|
||||
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 () => {
|
||||
mockValidateSession.mockResolvedValue({ exists: true })
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -118,6 +135,7 @@ describe('Identity Change Handling', () => {
|
||||
|
||||
expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true)
|
||||
expect(result.action).toBe('session_refreshed')
|
||||
expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,6 +165,24 @@ describe('Identity Change Handling', () => {
|
||||
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', () => {
|
||||
@@ -203,5 +239,49 @@ describe('Identity Change Handling', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user