import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useRealtimeAppState } from '../hooks/useRealtimeAppState'; import type { Channel, Contact, Conversation, HealthStatus, Message, RawPacket } from '../types'; const mocks = vi.hoisted(() => ({ api: { getChannels: vi.fn(), }, toast: { success: vi.fn(), error: vi.fn(), }, messageCache: { addMessage: vi.fn(), remove: vi.fn(), rename: vi.fn(), updateAck: vi.fn(), }, })); vi.mock('../api', () => ({ api: mocks.api, })); vi.mock('../components/ui/sonner', () => ({ toast: mocks.toast, })); vi.mock('../messageCache', () => mocks.messageCache); const publicChannel: Channel = { key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', name: 'Public', is_hashtag: false, on_radio: false, last_read_at: null, }; const incomingDm: Message = { id: 7, type: 'PRIV', conversation_key: 'aa'.repeat(32), text: 'hello', sender_timestamp: 1700000000, received_at: 1700000001, paths: null, txt_type: 0, signature: null, sender_key: 'aa'.repeat(32), outgoing: false, acked: 0, sender_name: 'Alice', }; function createRealtimeArgs(overrides: Partial[0]> = {}) { const setHealth = vi.fn(); const setRawPackets = vi.fn(); const setChannels = vi.fn(); const setContacts = vi.fn(); return { args: { prevHealthRef: { current: null as HealthStatus | null }, setHealth, fetchConfig: vi.fn(), setRawPackets, triggerReconcile: vi.fn(), refreshUnreads: vi.fn(async () => {}), setChannels, fetchAllContacts: vi.fn(async () => [] as Contact[]), setContacts, blockedKeysRef: { current: [] as string[] }, blockedNamesRef: { current: [] as string[] }, activeConversationRef: { current: null as Conversation | null }, hasNewerMessagesRef: { current: false }, addMessageIfNew: vi.fn(), trackNewMessage: vi.fn(), incrementUnread: vi.fn(), renameConversationState: vi.fn(), checkMention: vi.fn(() => false), pendingDeleteFallbackRef: { current: false }, setActiveConversation: vi.fn(), updateMessageAck: vi.fn(), notifyIncomingMessage: vi.fn(), ...overrides, }, fns: { setHealth, setRawPackets, setChannels, setContacts, }, }; } describe('useRealtimeAppState', () => { beforeEach(() => { vi.clearAllMocks(); mocks.api.getChannels.mockResolvedValue([publicChannel]); }); it('reconnect clears raw packets and refetches channels/contacts/unreads', async () => { const contacts: Contact[] = [ { public_key: 'bb'.repeat(32), name: 'Bob', type: 1, flags: 0, last_path: null, last_path_len: 0, out_path_hash_mode: 0, last_advert: null, lat: null, lon: null, last_seen: null, on_radio: false, last_contacted: null, last_read_at: null, first_seen: null, }, ]; const { args, fns } = createRealtimeArgs({ fetchAllContacts: vi.fn(async () => contacts), }); const { result } = renderHook(() => useRealtimeAppState(args)); act(() => { result.current.onReconnect?.(); }); await waitFor(() => { expect(args.triggerReconcile).toHaveBeenCalledTimes(1); expect(args.refreshUnreads).toHaveBeenCalledTimes(1); expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); expect(args.fetchAllContacts).toHaveBeenCalledTimes(1); expect(fns.setRawPackets).toHaveBeenCalledWith([]); expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]); expect(fns.setContacts).toHaveBeenCalledWith(contacts); }); }); it('tracks unread state for a new non-active incoming message', () => { mocks.messageCache.addMessage.mockReturnValue(true); const { args } = createRealtimeArgs({ checkMention: vi.fn(() => true), }); const { result } = renderHook(() => useRealtimeAppState(args)); act(() => { result.current.onMessage?.(incomingDm); }); expect(args.addMessageIfNew).not.toHaveBeenCalled(); expect(args.trackNewMessage).toHaveBeenCalledWith(incomingDm); expect(mocks.messageCache.addMessage).toHaveBeenCalledWith( incomingDm.conversation_key, incomingDm, expect.any(String) ); expect(args.incrementUnread).toHaveBeenCalledWith( `contact-${incomingDm.conversation_key}`, true ); expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm); }); it('deleting the active contact clears it and marks fallback recovery pending', () => { const pendingDeleteFallbackRef = { current: false }; const activeConversationRef = { current: { type: 'contact', id: incomingDm.conversation_key, name: 'Alice', } satisfies Conversation, }; const { args, fns } = createRealtimeArgs({ activeConversationRef, pendingDeleteFallbackRef, }); const { result } = renderHook(() => useRealtimeAppState(args)); act(() => { result.current.onContactDeleted?.(incomingDm.conversation_key); }); expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function)); expect(mocks.messageCache.remove).toHaveBeenCalledWith(incomingDm.conversation_key); expect(args.setActiveConversation).toHaveBeenCalledWith(null); expect(pendingDeleteFallbackRef.current).toBe(true); }); it('resolves a prefix-only contact into a full key and updates active conversation state', () => { const previousPublicKey = 'abc123def456'; const resolvedContact: Contact = { public_key: 'aa'.repeat(32), name: null, type: 0, flags: 0, last_path: null, last_path_len: -1, out_path_hash_mode: -1, last_advert: null, lat: null, lon: null, last_seen: null, on_radio: false, last_contacted: 1700000000, last_read_at: null, first_seen: 1700000000, }; const activeConversationRef = { current: { type: 'contact', id: previousPublicKey, name: 'abc123def456', } satisfies Conversation, }; const { args, fns } = createRealtimeArgs({ activeConversationRef, }); const { result } = renderHook(() => useRealtimeAppState(args)); act(() => { result.current.onContactResolved?.(previousPublicKey, resolvedContact); }); expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function)); expect(mocks.messageCache.rename).toHaveBeenCalledWith( previousPublicKey, resolvedContact.public_key ); expect(args.renameConversationState).toHaveBeenCalledWith( `contact-${previousPublicKey}`, `contact-${resolvedContact.public_key}` ); expect(args.setActiveConversation).toHaveBeenCalledWith({ type: 'contact', id: resolvedContact.public_key, name: '[unknown sender]', }); }); it('appends raw packets using observation identity dedup', () => { const { args, fns } = createRealtimeArgs(); const packet: RawPacket = { id: 1, observation_id: 2, timestamp: 1700000000, data: 'aabb', payload_type: 'GROUP_TEXT', snr: 7.5, rssi: -80, decrypted: false, decrypted_info: null, }; const { result } = renderHook(() => useRealtimeAppState(args)); act(() => { result.current.onRawPacket?.(packet); }); expect(fns.setRawPackets).toHaveBeenCalledWith(expect.any(Function)); }); });