Improve some coverage in integration form

This commit is contained in:
Jack Kingsman
2026-02-23 22:38:29 -08:00
parent ecb748b9e3
commit 559935e3d5
4 changed files with 399 additions and 16 deletions

View File

@@ -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));

View File

@@ -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> = {}): 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
});
});

View File

@@ -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());
});
});

View File

@@ -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];
}