diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b6fe325..e8b8b66 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ import { Toaster, toast } from './components/ui/sonner'; import { getStateKey } from './utils/conversationState'; import { appendRawPacketUnique } from './utils/rawPacketIdentity'; import { messageContainsMention } from './utils/messageParser'; +import { mergeContactIntoList } from './utils/contactMerge'; import { cn } from '@/lib/utils'; import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types'; @@ -265,21 +266,7 @@ export function App() { } }, onContact: (contact: Contact) => { - setContacts((prev) => { - const idx = prev.findIndex((c) => c.public_key === contact.public_key); - if (idx >= 0) { - const existing = prev[idx]; - const merged = { ...existing, ...contact }; - const unchanged = (Object.keys(merged) as (keyof Contact)[]).every( - (k) => existing[k] === merged[k] - ); - if (unchanged) return prev; - const updated = [...prev]; - updated[idx] = merged; - return updated; - } - return [...prev, contact as Contact]; - }); + setContacts((prev) => mergeContactIntoList(prev, contact)); }, onRawPacket: (packet: RawPacket) => { setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS)); diff --git a/frontend/src/test/integration.test.ts b/frontend/src/test/integration.test.ts index 9e616b4..a958ec7 100644 --- a/frontend/src/test/integration.test.ts +++ b/frontend/src/test/integration.test.ts @@ -12,8 +12,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import fixtures from './fixtures/websocket_events.json'; import { getMessageContentKey } from '../hooks/useConversationMessages'; import { getStateKey } from '../utils/conversationState'; +import { mergeContactIntoList } from '../utils/contactMerge'; import * as messageCache from '../messageCache'; -import type { Message } from '../types'; +import type { Contact, Message } from '../types'; /** * Minimal state for testing message dedup and unread logic. @@ -263,3 +264,175 @@ describe('Integration: State Key Contract', () => { expect(stateKey).toBe(`contact-${publicKey}`); }); }); + +// --- Contact merge tests (imports real mergeContactIntoList) --- + +function makeContact(overrides: Partial = {}): Contact { + return { + public_key: 'abc123', + name: 'TestNode', + type: 1, + flags: 0, + last_path: null, + last_path_len: 0, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: true, + last_contacted: null, + last_read_at: null, + ...overrides, + }; +} + +describe('Integration: Contact Merge', () => { + it('appends new contact to list', () => { + const existing = [makeContact({ public_key: 'aaa', name: 'Alpha' })]; + const incoming = makeContact({ public_key: 'bbb', name: 'Beta' }); + + const result = mergeContactIntoList(existing, incoming); + + expect(result).toHaveLength(2); + expect(result[1].name).toBe('Beta'); + }); + + it('merges existing contact (updates name, preserves other fields)', () => { + const existing = [makeContact({ public_key: 'aaa', name: 'Alpha', lat: 47.0 })]; + const incoming = makeContact({ public_key: 'aaa', name: 'Alpha-Updated' }); + + const result = mergeContactIntoList(existing, incoming); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Alpha-Updated'); + // Spread semantics: incoming lat (null) overwrites existing lat + expect(result[0].public_key).toBe('aaa'); + }); + + it('returns same array reference when contact is unchanged', () => { + const contact = makeContact({ public_key: 'aaa', name: 'Alpha' }); + const existing = [contact]; + // Incoming with identical values + const incoming = makeContact({ public_key: 'aaa', name: 'Alpha' }); + + const result = mergeContactIntoList(existing, incoming); + + expect(result).toBe(existing); // referential equality + }); + + it('partial update merges without clobbering unrelated fields', () => { + const existing = [makeContact({ public_key: 'aaa', name: 'Alpha', lat: 47.0, lon: -122.0 })]; + // Incoming update only changes lat + const incoming = makeContact({ public_key: 'aaa', name: 'Alpha', lat: 48.0, lon: -122.0 }); + + const result = mergeContactIntoList(existing, incoming); + + expect(result[0].lat).toBe(48.0); + expect(result[0].lon).toBe(-122.0); + expect(result[0].name).toBe('Alpha'); + }); +}); + +// --- ACK + messageCache propagation tests --- + +describe('Integration: ACK + messageCache propagation', () => { + beforeEach(() => { + messageCache.clear(); + }); + + it('updateAck updates acked count on cached message', () => { + const msg: Message = { + id: 100, + type: 'PRIV', + conversation_key: 'pk_abc', + text: 'Hello', + sender_timestamp: 1700000000, + received_at: 1700000000, + paths: null, + txt_type: 0, + signature: null, + outgoing: true, + acked: 0, + }; + messageCache.addMessage('pk_abc', msg, 'key-100'); + + messageCache.updateAck(100, 1); + + const entry = messageCache.get('pk_abc'); + expect(entry).toBeDefined(); + expect(entry!.messages[0].acked).toBe(1); + }); + + it('updateAck updates paths when longer', () => { + const msg: Message = { + id: 101, + type: 'PRIV', + conversation_key: 'pk_abc', + text: 'Test', + sender_timestamp: 1700000001, + received_at: 1700000001, + paths: [{ path: 'aa', received_at: 1700000001 }], + txt_type: 0, + signature: null, + outgoing: true, + acked: 1, + }; + messageCache.addMessage('pk_abc', msg, 'key-101'); + + const longerPaths = [ + { path: 'aa', received_at: 1700000001 }, + { path: 'bb', received_at: 1700000002 }, + ]; + messageCache.updateAck(101, 2, longerPaths); + + const entry = messageCache.get('pk_abc'); + expect(entry!.messages[0].paths).toHaveLength(2); + expect(entry!.messages[0].acked).toBe(2); + }); + + it('preserves higher existing ack count (max semantics)', () => { + const msg: Message = { + id: 102, + type: 'PRIV', + conversation_key: 'pk_abc', + text: 'Max test', + sender_timestamp: 1700000002, + received_at: 1700000002, + paths: null, + txt_type: 0, + signature: null, + outgoing: true, + acked: 5, + }; + messageCache.addMessage('pk_abc', msg, 'key-102'); + + // Try to update with a lower ack count + messageCache.updateAck(102, 3); + + const entry = messageCache.get('pk_abc'); + expect(entry!.messages[0].acked).toBe(5); // max(5, 3) = 5 + }); + + it('is a no-op for unknown message ID', () => { + const msg: Message = { + id: 103, + type: 'PRIV', + conversation_key: 'pk_abc', + text: 'Existing', + sender_timestamp: 1700000003, + received_at: 1700000003, + paths: null, + txt_type: 0, + signature: null, + outgoing: true, + acked: 0, + }; + messageCache.addMessage('pk_abc', msg, 'key-103'); + + // Update a non-existent message ID — should not throw or modify anything + messageCache.updateAck(999, 1); + + const entry = messageCache.get('pk_abc'); + expect(entry!.messages[0].acked).toBe(0); // unchanged + }); +}); diff --git a/frontend/src/test/useWebSocket.dispatch.test.ts b/frontend/src/test/useWebSocket.dispatch.test.ts new file mode 100644 index 0000000..88d2208 --- /dev/null +++ b/frontend/src/test/useWebSocket.dispatch.test.ts @@ -0,0 +1,198 @@ +/** + * Integration tests for useWebSocket onmessage dispatch. + * + * Verifies that the switch statement in useWebSocket.ts:91-134 routes + * incoming JSON messages to the correct handler callbacks with the + * correct data shapes. + */ + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useWebSocket } from '../useWebSocket'; +import fixtures from './fixtures/websocket_events.json'; + +class MockWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + static instances: MockWebSocket[] = []; + + url: string; + readyState = MockWebSocket.OPEN; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: ((error: unknown) => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + close(): void { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(); + } + + send(): void {} +} + +const originalWebSocket = globalThis.WebSocket; + +/** Send a JSON message through the most recent MockWebSocket instance. */ +function fireMessage(data: unknown): void { + const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; + act(() => { + ws.onmessage?.({ data: JSON.stringify(data) }); + }); +} + +describe('useWebSocket dispatch', () => { + beforeEach(() => { + vi.useFakeTimers(); + MockWebSocket.instances = []; + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + }); + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + vi.useRealTimers(); + }); + + it('routes health message to onHealth', () => { + const onHealth = vi.fn(); + renderHook(() => useWebSocket({ onHealth })); + + const healthData = { + status: 'ok', + radio_connected: true, + connection_info: 'TCP: 1.2.3.4:4000', + database_size_mb: 1.5, + oldest_undecrypted_timestamp: null, + }; + fireMessage({ type: 'health', data: healthData }); + + expect(onHealth).toHaveBeenCalledOnce(); + expect(onHealth).toHaveBeenCalledWith(healthData); + }); + + it('routes message event to onMessage with correct Message shape', () => { + const onMessage = vi.fn(); + renderHook(() => useWebSocket({ onMessage })); + + const { type, data } = fixtures.channel_message.expected_ws_event; + fireMessage({ type, data }); + + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith(data); + }); + + it('routes contact event to onContact with correct Contact shape', () => { + const onContact = vi.fn(); + renderHook(() => useWebSocket({ onContact })); + + const { type, data } = fixtures.advertisement_with_gps.expected_ws_event; + fireMessage({ type, data }); + + expect(onContact).toHaveBeenCalledOnce(); + expect(onContact).toHaveBeenCalledWith(data); + expect(onContact.mock.calls[0][0]).toHaveProperty('public_key'); + expect(onContact.mock.calls[0][0]).toHaveProperty('name'); + }); + + it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => { + const onMessageAcked = vi.fn(); + renderHook(() => useWebSocket({ onMessageAcked })); + + const { type, data } = fixtures.message_acked.expected_ws_event; + fireMessage({ type, data }); + + expect(onMessageAcked).toHaveBeenCalledOnce(); + expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined); + }); + + it('routes message_acked with paths', () => { + const onMessageAcked = vi.fn(); + renderHook(() => useWebSocket({ onMessageAcked })); + + const paths = [{ path: 'aabb', received_at: 1700000000 }]; + fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } }); + + expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths); + }); + + it('routes error event to onError', () => { + const onError = vi.fn(); + renderHook(() => useWebSocket({ onError })); + + const errorData = { message: 'Send failed', details: 'Radio busy' }; + fireMessage({ type: 'error', data: errorData }); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(errorData); + }); + + it('routes success event to onSuccess', () => { + const onSuccess = vi.fn(); + renderHook(() => useWebSocket({ onSuccess })); + + const successData = { message: 'Message sent' }; + fireMessage({ type: 'success', data: successData }); + + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onSuccess).toHaveBeenCalledWith(successData); + }); + + it('pong message calls no handlers', () => { + const handlers = { + onHealth: vi.fn(), + onMessage: vi.fn(), + onContact: vi.fn(), + onMessageAcked: vi.fn(), + onError: vi.fn(), + onSuccess: vi.fn(), + }; + renderHook(() => useWebSocket(handlers)); + + fireMessage({ type: 'pong', data: null }); + + Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled()); + }); + + it('unknown message type calls no handlers', () => { + const handlers = { + onHealth: vi.fn(), + onMessage: vi.fn(), + onContact: vi.fn(), + onMessageAcked: vi.fn(), + onError: vi.fn(), + onSuccess: vi.fn(), + }; + renderHook(() => useWebSocket(handlers)); + + fireMessage({ type: 'something_unexpected', data: {} }); + + Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled()); + }); + + it('malformed JSON calls no handlers (catch branch)', () => { + const handlers = { + onHealth: vi.fn(), + onMessage: vi.fn(), + onContact: vi.fn(), + onMessageAcked: vi.fn(), + onError: vi.fn(), + onSuccess: vi.fn(), + }; + renderHook(() => useWebSocket(handlers)); + + const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; + act(() => { + ws.onmessage?.({ data: 'not valid json{{{' }); + }); + + Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled()); + }); +}); diff --git a/frontend/src/utils/contactMerge.ts b/frontend/src/utils/contactMerge.ts new file mode 100644 index 0000000..dc9130d --- /dev/null +++ b/frontend/src/utils/contactMerge.ts @@ -0,0 +1,25 @@ +import type { Contact } from '../types'; + +/** + * Merge an incoming contact into an existing list. + * + * - If the contact exists (matched by public_key), merge fields and return + * a new array only if something changed (preserves referential equality + * for React when nothing changed). + * - If the contact is new, append it. + */ +export function mergeContactIntoList(contacts: Contact[], incoming: Contact): Contact[] { + const idx = contacts.findIndex((c) => c.public_key === incoming.public_key); + if (idx >= 0) { + const existing = contacts[idx]; + const merged = { ...existing, ...incoming }; + const unchanged = (Object.keys(merged) as (keyof Contact)[]).every( + (k) => existing[k] === merged[k] + ); + if (unchanged) return contacts; + const updated = [...contacts]; + updated[idx] = merged; + return updated; + } + return [...contacts, incoming]; +}