Merge branch 'WhiskeySockets:master' into master

This commit is contained in:
Renato Alcara
2026-01-20 09:58:02 -03:00
committed by GitHub
17 changed files with 1248 additions and 109 deletions
+94
View File
@@ -0,0 +1,94 @@
import { proto } from '../../../WAProto/index.js'
import { processHistoryMessage } from '../../Utils/history'
describe('processHistoryMessage', () => {
describe('phoneNumberToLidMappings extraction', () => {
it('should extract LID-PN mappings from history sync payload', () => {
const historySync: proto.IHistorySync = {
syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP,
conversations: [],
phoneNumberToLidMappings: [
{ lidJid: '11111111111111@lid', pnJid: '1234567890123@s.whatsapp.net' },
{ lidJid: '22222222222222@lid', pnJid: '9876543210987@s.whatsapp.net' }
]
}
const result = processHistoryMessage(historySync)
expect(result.lidPnMappings).toEqual([
{ lid: '11111111111111@lid', pn: '1234567890123@s.whatsapp.net' },
{ lid: '22222222222222@lid', pn: '9876543210987@s.whatsapp.net' }
])
})
it('should skip mappings with missing lidJid or pnJid', () => {
const historySync: proto.IHistorySync = {
syncType: proto.HistorySync.HistorySyncType.RECENT,
conversations: [],
phoneNumberToLidMappings: [
{ lidJid: undefined, pnJid: '1234567890123@s.whatsapp.net' },
{ lidJid: '11111111111111@lid', pnJid: undefined },
{ lidJid: '22222222222222@lid', pnJid: '9876543210987@s.whatsapp.net' }
]
}
const result = processHistoryMessage(historySync)
expect(result.lidPnMappings).toEqual([{ lid: '22222222222222@lid', pn: '9876543210987@s.whatsapp.net' }])
})
it('should return empty array when no mappings exist', () => {
const historySync: proto.IHistorySync = {
syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP,
conversations: []
}
const result = processHistoryMessage(historySync)
expect(result.lidPnMappings).toEqual([])
})
it('should process mappings regardless of sync type', () => {
const syncTypes = [proto.HistorySync.HistorySyncType.PUSH_NAME, proto.HistorySync.HistorySyncType.ON_DEMAND]
for (const syncType of syncTypes) {
const historySync: proto.IHistorySync = {
syncType,
conversations: [],
pushnames: [],
phoneNumberToLidMappings: [{ lidJid: '11111111111111@lid', pnJid: '1234567890123@s.whatsapp.net' }]
}
const result = processHistoryMessage(historySync)
expect(result.lidPnMappings).toEqual([{ lid: '11111111111111@lid', pn: '1234567890123@s.whatsapp.net' }])
}
})
})
describe('conversations processing', () => {
it('should extract contacts with LID and PN from conversations', () => {
const historySync: proto.IHistorySync = {
syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP,
conversations: [
{
id: '1234567890123@s.whatsapp.net',
name: 'Test User',
lidJid: '11111111111111@lid',
pnJid: '1234567890123@s.whatsapp.net'
}
]
}
const result = processHistoryMessage(historySync)
expect(result.contacts).toHaveLength(1)
expect(result.contacts[0]).toEqual({
id: '1234567890123@s.whatsapp.net',
name: 'Test User',
lid: '11111111111111@lid',
phoneNumber: '1234567890123@s.whatsapp.net'
})
})
})
})
+339
View File
@@ -0,0 +1,339 @@
import { jest } from '@jest/globals'
import { NOISE_WA_HEADER } from '../../Defaults'
import { Curve } from '../../Utils/crypto'
import { makeNoiseHandler } from '../../Utils/noise-handler'
import type { BinaryNode } from '../../WABinary/types'
// Create a mock logger
const createMockLogger = () => ({
child: jest.fn().mockReturnThis(),
trace: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
level: 'trace'
})
// Helper to create a frame with length prefix
const createFrame = (payload: Buffer) => {
const frame = Buffer.alloc(3 + payload.length)
frame.writeUInt8(payload.length >> 16, 0)
frame.writeUInt16BE(payload.length & 0xffff, 1)
payload.copy(frame, 3)
return frame
}
describe('Noise Handler', () => {
describe('decodeFrame with multiple frames in buffer', () => {
it('should process multiple unencrypted frames in single buffer', async () => {
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
const payload1 = Buffer.from([1, 2, 3, 4, 5])
const payload2 = Buffer.from([6, 7, 8, 9, 10])
const frame1 = createFrame(payload1)
const frame2 = createFrame(payload2)
const combinedBuffer = Buffer.concat([frame1, frame2])
const receivedFrames: Buffer[] = []
const onFrame = (frame: Uint8Array | BinaryNode) => {
receivedFrames.push(Buffer.from(frame as Uint8Array))
}
await handler.decodeFrame(combinedBuffer, onFrame)
expect(receivedFrames).toHaveLength(2)
expect(receivedFrames[0]).toEqual(payload1)
expect(receivedFrames[1]).toEqual(payload2)
})
it('should handle frames split across multiple decodeFrame calls', async () => {
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
const payload = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
const frame = createFrame(payload)
const receivedFrames: Buffer[] = []
const onFrame = (frame: Uint8Array | BinaryNode) => {
receivedFrames.push(Buffer.from(frame as Uint8Array))
}
// Split the frame across two calls
const part1 = frame.slice(0, 5)
const part2 = frame.slice(5)
await handler.decodeFrame(part1, onFrame)
expect(receivedFrames).toHaveLength(0)
await handler.decodeFrame(part2, onFrame)
expect(receivedFrames).toHaveLength(1)
expect(receivedFrames[0]).toEqual(payload)
})
it('should correctly process frames when callback triggers async operations', async () => {
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
const payload1 = Buffer.from([1, 2, 3])
const payload2 = Buffer.from([4, 5, 6])
const combinedBuffer = Buffer.concat([createFrame(payload1), createFrame(payload2)])
const receivedFrames: Buffer[] = []
const callbackOrder: number[] = []
const onFrame = (frame: Uint8Array | BinaryNode) => {
const frameNum = receivedFrames.length + 1
callbackOrder.push(frameNum)
receivedFrames.push(Buffer.from(frame as Uint8Array))
}
await handler.decodeFrame(combinedBuffer, onFrame)
expect(receivedFrames).toHaveLength(2)
expect(callbackOrder).toEqual([1, 2])
expect(receivedFrames[0]).toEqual(payload1)
expect(receivedFrames[1]).toEqual(payload2)
})
})
describe('encrypted frame handling', () => {
it('should encrypt and verify frame structure', async () => {
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
await handler.finishInit()
const payload = Buffer.from('test payload')
const encoded = handler.encodeFrame(payload)
expect(encoded.length).toBeGreaterThan(payload.length + 3)
const encoded2 = handler.encodeFrame(Buffer.from('second payload'))
expect(encoded2.slice(0, 3)).toEqual(Buffer.from([0, 0, encoded2.length - 3]))
})
it('should produce different ciphertext for same plaintext due to counter', async () => {
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
await handler.finishInit()
const payload = Buffer.from('same payload')
const encrypted1 = handler.encrypt(payload)
const encrypted2 = handler.encrypt(payload)
const encrypted3 = handler.encrypt(payload)
expect(encrypted1).not.toEqual(encrypted2)
expect(encrypted2).not.toEqual(encrypted3)
expect(encrypted1).not.toEqual(encrypted3)
})
})
describe('race condition scenario - concurrent decodeFrame calls', () => {
it('should handle concurrent decodeFrame calls without corrupting inBytes buffer', async () => {
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
// Create multiple frames
const payloads = Array.from({ length: 5 }, (_, i) => Buffer.from(`payload-${i}`))
const frames = payloads.map(createFrame)
const receivedFrames: Buffer[] = []
const onFrame = (frame: Uint8Array | BinaryNode) => {
receivedFrames.push(Buffer.from(frame as Uint8Array))
}
// Simulate concurrent calls (multiple WebSocket messages arriving rapidly)
// This tests the shared inBytes buffer handling
await Promise.all(frames.map(frame => handler.decodeFrame(frame, onFrame)))
// All frames should be received
expect(receivedFrames).toHaveLength(5)
// Verify all payloads are present (order may vary due to concurrency)
const receivedPayloads = receivedFrames.map(f => f.toString())
payloads.forEach(p => {
expect(receivedPayloads).toContain(p.toString())
})
})
it('should maintain counter integrity with many frames in single buffer', async () => {
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
// Create 10 frames to stress test the while loop
const payloads = Array.from({ length: 10 }, (_, i) => Buffer.from(`frame-${i}-payload-data`))
const combinedBuffer = Buffer.concat(payloads.map(createFrame))
const receivedFrames: Buffer[] = []
const onFrame = (frame: Uint8Array | BinaryNode) => {
receivedFrames.push(Buffer.from(frame as Uint8Array))
}
await handler.decodeFrame(combinedBuffer, onFrame)
expect(receivedFrames).toHaveLength(10)
payloads.forEach((payload, i) => {
expect(receivedFrames[i]).toEqual(payload)
})
})
})
describe('encrypted frame race condition', () => {
it('should produce different ciphertext for same plaintext due to counter', async () => {
// Verify that encryption uses incrementing counters
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
await handler.finishInit()
const payload1 = Buffer.from('message-1')
const payload2 = Buffer.from('message-2')
const encrypted1 = handler.encrypt(payload1)
const encrypted2 = handler.encrypt(payload2)
expect(encrypted1.length).toBe(payload1.length + 16) // +16 for GCM tag
expect(encrypted2.length).toBe(payload2.length + 16)
// The encrypted data should be different (different counters used)
expect(encrypted1).not.toEqual(encrypted2)
})
it('should serialize concurrent decodeFrame calls (fix for race condition)', async () => {
// This test verifies that the lock mechanism correctly serializes
// concurrent decodeFrame calls, preventing race conditions
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
const payload1 = Buffer.from('first')
const payload2 = Buffer.from('second')
const payload3 = Buffer.from('third')
const frame1 = createFrame(payload1)
const frame2 = createFrame(payload2)
const frame3 = createFrame(payload3)
const receivedOrder: string[] = []
const onFrame = (frame: Uint8Array | BinaryNode) => {
const content = Buffer.from(frame as Uint8Array).toString()
receivedOrder.push(content)
}
// Start all three decodeFrame calls "simultaneously"
// With the lock fix, they should be processed in order
const p1 = handler.decodeFrame(frame1, onFrame)
const p2 = handler.decodeFrame(frame2, onFrame)
const p3 = handler.decodeFrame(frame3, onFrame)
await Promise.all([p1, p2, p3])
// With serialization, frames should be received in the order
// the decodeFrame calls were made
expect(receivedOrder).toHaveLength(3)
expect(receivedOrder[0]).toBe('first')
expect(receivedOrder[1]).toBe('second')
expect(receivedOrder[2]).toBe('third')
})
it('should maintain frame order with interleaved partial frames after fix', async () => {
// This test verifies that partial frames from different sources
// are correctly reassembled when calls are serialized
const keyPair = Curve.generateKeyPair()
const logger = createMockLogger()
const handler = makeNoiseHandler({
keyPair,
NOISE_HEADER: NOISE_WA_HEADER,
logger: logger as any
})
// Create a single frame split into parts
const payload = Buffer.from('complete-message-content')
const frame = createFrame(payload)
const part1 = frame.slice(0, 10)
const part2 = frame.slice(10)
const receivedFrames: Buffer[] = []
const onFrame = (frame: Uint8Array | BinaryNode) => {
receivedFrames.push(Buffer.from(frame as Uint8Array))
}
// With serialization, these should be processed in order
// and the frame should be correctly reassembled
await handler.decodeFrame(part1, onFrame)
expect(receivedFrames).toHaveLength(0) // Not complete yet
await handler.decodeFrame(part2, onFrame)
expect(receivedFrames).toHaveLength(1)
expect(receivedFrames[0]).toEqual(payload)
})
})
})
@@ -0,0 +1,359 @@
import { jest } from '@jest/globals'
import { proto } from '../../../WAProto/index.js'
import type { BaileysEventEmitter, ChatMutation, Contact } from '../../Types'
import { LabelAssociationType } from '../../Types/LabelAssociation'
import { processSyncAction } from '../../Utils/chat-utils'
import type { ILogger } from '../../Utils/logger'
const createMockEventEmitter = () => {
const emittedEvents: Array<{ event: string; data: unknown }> = []
const emit = jest.fn((event: string, data: unknown) => {
emittedEvents.push({ event, data })
return true
})
return {
emit,
emittedEvents,
on: jest.fn(),
off: jest.fn(),
removeAllListeners: jest.fn()
} as unknown as BaileysEventEmitter & { emittedEvents: typeof emittedEvents }
}
const createMockLogger = (): ILogger =>
({
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
trace: jest.fn(),
child: jest.fn(function (this: ILogger) {
return this
}),
level: 'silent'
}) as unknown as ILogger
const createSyncAction = (
action: proto.ISyncActionValue,
index: string[] = ['type', 'id', 'msgId', '0']
): ChatMutation => ({
syncAction: { value: action },
index
})
const mockMe: Contact = { id: 'me@s.whatsapp.net', name: 'Test User' }
describe('processSyncAction', () => {
let ev: ReturnType<typeof createMockEventEmitter>
let logger: ILogger
beforeEach(() => {
jest.clearAllMocks()
ev = createMockEventEmitter()
logger = createMockLogger()
})
describe('muteAction', () => {
it('emits chats.update with muteEndTime when muted', () => {
const syncAction = createSyncAction({ muteAction: { muted: true, muteEndTimestamp: 1700000000 } }, [
'mute',
'chat123@s.whatsapp.net'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ id: 'chat123@s.whatsapp.net', muteEndTime: 1700000000 })])
)
})
it('emits null muteEndTime when unmuted', () => {
const syncAction = createSyncAction({ muteAction: { muted: false, muteEndTimestamp: 0 } }, [
'mute',
'chat123@s.whatsapp.net'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ muteEndTime: null })])
)
})
})
describe('archiveChatAction', () => {
it('emits chats.update with archived true/false', () => {
const archived = createSyncAction({ archiveChatAction: { archived: true } }, ['archive', 'chat@s.whatsapp.net'])
processSyncAction(archived, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ archived: true })])
)
})
it('handles type fallback without archiveChatAction', () => {
const syncAction = createSyncAction({}, ['archive', 'chat@s.whatsapp.net'])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ archived: true })])
)
})
})
describe('markChatAsReadAction', () => {
it('emits unreadCount 0 when read is true', () => {
const read = createSyncAction({ markChatAsReadAction: { read: true } }, ['markRead', 'chat@s.whatsapp.net'])
processSyncAction(read, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ unreadCount: 0 })])
)
})
it('emits unreadCount -1 when read is false', () => {
const unread = createSyncAction({ markChatAsReadAction: { read: false } }, ['markRead', 'chat@s.whatsapp.net'])
processSyncAction(unread, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ unreadCount: -1 })])
)
})
it('emits null unreadCount during initial sync when already read', () => {
const syncAction = createSyncAction({ markChatAsReadAction: { read: true } }, ['markRead', 'chat@s.whatsapp.net'])
processSyncAction(syncAction, ev, mockMe, { accountSettings: { unarchiveChats: false } }, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ unreadCount: null })])
)
})
})
describe('deleteMessageForMeAction', () => {
it('emits messages.delete with correct key', () => {
const syncAction = createSyncAction({ deleteMessageForMeAction: { deleteMedia: false } }, [
'deleteMessageForMe',
'chat@s.whatsapp.net',
'msg456',
'1'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('messages.delete', {
keys: [{ remoteJid: 'chat@s.whatsapp.net', id: 'msg456', fromMe: true }]
})
})
})
describe('contactAction', () => {
it('emits contacts.upsert and lid-mapping.update for PN user with LID', () => {
const syncAction = createSyncAction({ contactAction: { fullName: 'John', lidJid: '123@lid', pnJid: null } }, [
'contact',
'5511999@s.whatsapp.net'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('contacts.upsert', [
{
id: '5511999@s.whatsapp.net',
name: 'John',
lid: '123@lid',
phoneNumber: '5511999@s.whatsapp.net'
}
])
expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', {
lid: '123@lid',
pn: '5511999@s.whatsapp.net'
})
})
it('does not emit events when id is missing', () => {
const syncAction = createSyncAction({ contactAction: { fullName: 'John', lidJid: '123@lid', pnJid: null } }, [
'contact',
''
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emittedEvents.filter(e => e.event === 'contacts.upsert')).toHaveLength(0)
})
})
describe('pushNameSetting', () => {
it('emits creds.update when name differs', () => {
const syncAction = createSyncAction({ pushNameSetting: { name: 'New' } }, ['pushName'])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('creds.update', { me: { ...mockMe, name: 'New' } })
})
it('does not emit when name is same or empty', () => {
const same = createSyncAction({ pushNameSetting: { name: 'Test User' } }, ['pushName'])
processSyncAction(same, ev, mockMe, undefined, logger)
expect(ev.emit).not.toHaveBeenCalled()
})
})
describe('pinAction', () => {
it('emits chats.update with pinned timestamp or null', () => {
const syncAction: ChatMutation = {
syncAction: { value: { pinAction: { pinned: true }, timestamp: 1700000000 } },
index: ['pin', 'chat@s.whatsapp.net']
}
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith(
'chats.update',
expect.arrayContaining([expect.objectContaining({ pinned: 1700000000 })])
)
})
})
describe('starAction', () => {
it('emits messages.update with starred value', () => {
const syncAction = createSyncAction({ starAction: { starred: true } }, [
'star',
'chat@s.whatsapp.net',
'msg',
'1'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('messages.update', [
{
key: { remoteJid: 'chat@s.whatsapp.net', id: 'msg', fromMe: true },
update: { starred: true }
}
])
})
})
describe('deleteChatAction', () => {
it('emits chats.delete when not initial sync', () => {
const syncAction = createSyncAction({ deleteChatAction: { messageRange: null } }, [
'deleteChat',
'chat@s.whatsapp.net'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('chats.delete', ['chat@s.whatsapp.net'])
})
it('does NOT emit during initial sync', () => {
const syncAction = createSyncAction({ deleteChatAction: { messageRange: null } }, [
'deleteChat',
'chat@s.whatsapp.net'
])
processSyncAction(syncAction, ev, mockMe, { accountSettings: { unarchiveChats: false } }, logger)
expect(ev.emit).not.toHaveBeenCalled()
})
})
describe('labelEditAction', () => {
it('emits labels.edit', () => {
const syncAction = createSyncAction(
{ labelEditAction: { name: 'Important', color: 1, deleted: false, predefinedId: 5 } },
['label', 'label123']
)
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('labels.edit', {
id: 'label123',
name: 'Important',
color: 1,
deleted: false,
predefinedId: '5'
})
})
})
describe('labelAssociationAction', () => {
it('emits labels.association for chat label', () => {
const syncAction = createSyncAction({ labelAssociationAction: { labeled: true } }, [
LabelAssociationType.Chat,
'label123',
'chat@s.whatsapp.net'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('labels.association', {
type: 'add',
association: { type: LabelAssociationType.Chat, chatId: 'chat@s.whatsapp.net', labelId: 'label123' }
})
})
it('emits labels.association for message label', () => {
const syncAction = createSyncAction({ labelAssociationAction: { labeled: true } }, [
LabelAssociationType.Message,
'label123',
'chat@s.whatsapp.net',
'msg789'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('labels.association', {
type: 'add',
association: {
type: LabelAssociationType.Message,
chatId: 'chat@s.whatsapp.net',
messageId: 'msg789',
labelId: 'label123'
}
})
})
})
describe('pnForLidChatAction', () => {
it('emits lid-mapping.update when pnJid is present', () => {
const syncAction = createSyncAction({ pnForLidChatAction: { pnJid: '5511999@s.whatsapp.net' } }, [
'pnForLid',
'123@lid'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', { lid: '123@lid', pn: '5511999@s.whatsapp.net' })
})
it('does not emit when pnJid is missing', () => {
const syncAction = createSyncAction({ pnForLidChatAction: { pnJid: '' } }, ['pnForLid', '123@lid'])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).not.toHaveBeenCalled()
})
})
describe('lockChatAction', () => {
it('emits chats.lock', () => {
const syncAction = createSyncAction({ lockChatAction: { locked: true } }, ['lockChat', 'chat@s.whatsapp.net'])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('chats.lock', { id: 'chat@s.whatsapp.net', locked: true })
})
})
describe('lidContactAction', () => {
it('emits contacts.upsert with LID contact', () => {
const syncAction = createSyncAction({ lidContactAction: { fullName: 'LID Contact' } }, ['lidContact', '123@lid'])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('contacts.upsert', [
{
id: '123@lid',
name: 'LID Contact',
lid: '123@lid',
phoneNumber: undefined
}
])
})
})
describe('settings actions', () => {
it('localeSetting emits settings.update', () => {
const syncAction = createSyncAction({ localeSetting: { locale: 'en-US' } }, ['locale'])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('settings.update', { setting: 'locale', value: 'en-US' })
})
it('unarchiveChatsSetting emits creds.update', () => {
const syncAction = createSyncAction({ unarchiveChatsSetting: { unarchiveChats: true } }, ['unarchiveChats'])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(ev.emit).toHaveBeenCalledWith('creds.update', { accountSettings: { unarchiveChats: true } })
})
})
describe('unprocessable actions', () => {
it('logs debug for unknown action', () => {
const syncAction = createSyncAction({ unknownAction: {} } as unknown as proto.ISyncActionValue, [
'unknown',
'id123'
])
processSyncAction(syncAction, ev, mockMe, undefined, logger)
expect(logger.debug).toHaveBeenCalledWith({ syncAction, id: 'id123' }, 'unprocessable update')
expect(ev.emit).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,184 @@
import { jest } from '@jest/globals'
import type { ILogger } from '../../Utils/logger'
import { processContactAction } from '../../Utils/sync-action-utils'
describe('processContactAction', () => {
const mockLogger: ILogger = {
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
trace: jest.fn(),
child: jest.fn(() => mockLogger),
level: 'silent'
} as unknown as ILogger
beforeEach(() => {
jest.clearAllMocks()
})
describe('contacts.upsert', () => {
it('emits with phoneNumber from id when id is PN user', () => {
const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null }
const id = '5511999999999@s.whatsapp.net'
const results = processContactAction(action, id)
expect(results).toContainEqual({
event: 'contacts.upsert',
data: [
{
id: '5511999999999@s.whatsapp.net',
name: 'John Doe',
lid: '123456789@lid',
phoneNumber: '5511999999999@s.whatsapp.net'
}
]
})
})
it('uses pnJid as phoneNumber fallback when id is LID user', () => {
const action = { fullName: 'John Doe', lidJid: null, pnJid: '5511888888888@s.whatsapp.net' }
const id = '123456789@lid'
const results = processContactAction(action, id)
expect(results).toContainEqual({
event: 'contacts.upsert',
data: [
{
id: '123456789@lid',
name: 'John Doe',
lid: undefined,
phoneNumber: '5511888888888@s.whatsapp.net'
}
]
})
})
it('handles undefined fullName', () => {
const action = { fullName: undefined, lidJid: '123456789@lid', pnJid: null }
const id = '5511999999999@s.whatsapp.net'
const results = processContactAction(action, id)
const contactData = results.find(r => r.event === 'contacts.upsert')!.data
expect(contactData[0]!.name).toBeUndefined()
})
})
describe('lid-mapping.update', () => {
it('emits when LID and PN are valid', () => {
const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null }
const id = '5511999999999@s.whatsapp.net'
const results = processContactAction(action, id)
expect(results).toContainEqual({
event: 'lid-mapping.update',
data: { lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' }
})
})
it('handles LID with device ID suffix', () => {
const action = { fullName: 'Contact', lidJid: '173233882013816:99@lid', pnJid: null }
const id = '5511999999999@s.whatsapp.net'
const results = processContactAction(action, id)
expect(results).toContainEqual({
event: 'lid-mapping.update',
data: { lid: '173233882013816:99@lid', pn: '5511999999999@s.whatsapp.net' }
})
})
it('does NOT emit when lidJid is missing', () => {
const action = { fullName: 'John Doe', lidJid: null, pnJid: null }
const id = '5511999999999@s.whatsapp.net'
const results = processContactAction(action, id)
expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined()
})
it('does NOT emit when id is LID user (not PN)', () => {
const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null }
const id = '987654321@lid'
const results = processContactAction(action, id)
expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined()
})
it('does NOT emit when lidJid is invalid format', () => {
const action = { fullName: 'John Doe', lidJid: 'invalid-lid-format', pnJid: null }
const id = '5511999999999@s.whatsapp.net'
const results = processContactAction(action, id)
expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined()
})
it('does NOT emit for group JIDs', () => {
const action = { fullName: 'Group', lidJid: '123456789@lid', pnJid: null }
const id = '123456789012345678@g.us'
const results = processContactAction(action, id)
expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined()
})
})
describe('missing id', () => {
it('returns empty array and logs warning when id is undefined', () => {
const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null }
const results = processContactAction(action, undefined, mockLogger)
expect(results).toEqual([])
expect(mockLogger.warn).toHaveBeenCalledWith(
{ hasFullName: true, hasLidJid: true, hasPnJid: false },
'contactAction sync: missing id in index'
)
})
it('returns empty array when id is empty string', () => {
const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null }
const results = processContactAction(action, '', mockLogger)
expect(results).toEqual([])
})
})
describe('PN extraction from index[1] (pnJid is always null)', () => {
it('extracts PN from id when pnJid is null', () => {
// In real WhatsApp data, pnJid is ALWAYS null - PN comes from index[1]
const action = { fullName: 'Test Contact', lidJid: '111222333@lid', pnJid: null }
const id = '5599887766@s.whatsapp.net'
const results = processContactAction(action, id)
const contactData = results.find(r => r.event === 'contacts.upsert')!.data
expect(contactData[0]!.phoneNumber).toBe('5599887766@s.whatsapp.net')
expect(contactData[0]!.lid).toBe('111222333@lid')
const mapping = results.find(r => r.event === 'lid-mapping.update')!.data
expect(mapping).toEqual({ lid: '111222333@lid', pn: '5599887766@s.whatsapp.net' })
})
it('prefers id over pnJid when id is PN user', () => {
const action = { fullName: 'Test', lidJid: '111222333@lid', pnJid: '1111111111@s.whatsapp.net' }
const id = '9999999999@s.whatsapp.net'
const results = processContactAction(action, id)
const contactData = results.find(r => r.event === 'contacts.upsert')!.data
expect(contactData[0]!.phoneNumber).toBe('9999999999@s.whatsapp.net')
const mapping = results.find(r => r.event === 'lid-mapping.update')!.data
expect(mapping.pn).toBe('9999999999@s.whatsapp.net')
})
})
})