Files
Remote-Terminal-for-MeshCore/frontend/src/test/websocket.test.ts

273 lines
8.7 KiB
TypeScript

/**
* Tests for WebSocket message parsing.
*
* These tests verify that WebSocket messages are correctly parsed
* and routed to the appropriate handlers.
*/
import { describe, it, expect, vi } from 'vitest';
import type { HealthStatus, Contact, Channel, Message, MessagePath, RawPacket } from '../types';
/**
* Parse and route a WebSocket message.
* Extracted logic from useWebSocket.ts for testing.
*/
function parseWebSocketMessage(
data: string,
handlers: {
onHealth?: (health: HealthStatus) => void;
onContacts?: (contacts: Contact[]) => void;
onChannels?: (channels: Channel[]) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
}
): { type: string; handled: boolean } {
try {
const msg = JSON.parse(data);
switch (msg.type) {
case 'health':
handlers.onHealth?.(msg.data as HealthStatus);
return { type: msg.type, handled: !!handlers.onHealth };
case 'contacts':
handlers.onContacts?.(msg.data as Contact[]);
return { type: msg.type, handled: !!handlers.onContacts };
case 'channels':
handlers.onChannels?.(msg.data as Channel[]);
return { type: msg.type, handled: !!handlers.onChannels };
case 'message':
handlers.onMessage?.(msg.data as Message);
return { type: msg.type, handled: !!handlers.onMessage };
case 'contact':
handlers.onContact?.(msg.data as Contact);
return { type: msg.type, handled: !!handlers.onContact };
case 'raw_packet':
handlers.onRawPacket?.(msg.data as RawPacket);
return { type: msg.type, handled: !!handlers.onRawPacket };
case 'message_acked': {
const ackData = msg.data as {
message_id: number;
ack_count: number;
paths?: MessagePath[];
};
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths);
return { type: msg.type, handled: !!handlers.onMessageAcked };
}
case 'pong':
return { type: msg.type, handled: true };
default:
return { type: msg.type, handled: false };
}
} catch {
return { type: 'error', handled: false };
}
}
describe('parseWebSocketMessage', () => {
it('routes health message to onHealth handler', () => {
const onHealth = vi.fn();
const data = JSON.stringify({
type: 'health',
data: { radio_connected: true, serial_port: '/dev/ttyUSB0' },
});
const result = parseWebSocketMessage(data, { onHealth });
expect(result.type).toBe('health');
expect(result.handled).toBe(true);
expect(onHealth).toHaveBeenCalledWith({
radio_connected: true,
serial_port: '/dev/ttyUSB0',
});
});
it('routes message_acked to onMessageAcked with message ID and ack count', () => {
const onMessageAcked = vi.fn();
const data = JSON.stringify({
type: 'message_acked',
data: { message_id: 42, ack_count: 3 },
});
const result = parseWebSocketMessage(data, { onMessageAcked });
expect(result.type).toBe('message_acked');
expect(result.handled).toBe(true);
expect(onMessageAcked).toHaveBeenCalledWith(42, 3, undefined);
});
it('routes message_acked with paths array', () => {
const onMessageAcked = vi.fn();
const paths = [
{ path: '1A2B', received_at: 1700000000 },
{ path: '1A3C', received_at: 1700000005 },
];
const data = JSON.stringify({
type: 'message_acked',
data: { message_id: 42, ack_count: 2, paths },
});
const result = parseWebSocketMessage(data, { onMessageAcked });
expect(result.type).toBe('message_acked');
expect(result.handled).toBe(true);
expect(onMessageAcked).toHaveBeenCalledWith(42, 2, paths);
});
it('routes new message to onMessage handler', () => {
const onMessage = vi.fn();
const messageData = {
id: 123,
type: 'CHAN',
channel_idx: 0,
text: 'Hello',
received_at: 1700000000,
outgoing: false,
acked: 0,
};
const data = JSON.stringify({ type: 'message', data: messageData });
const result = parseWebSocketMessage(data, { onMessage });
expect(result.type).toBe('message');
expect(result.handled).toBe(true);
expect(onMessage).toHaveBeenCalledWith(messageData);
});
it('handles pong messages silently', () => {
const data = JSON.stringify({ type: 'pong' });
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('pong');
expect(result.handled).toBe(true);
});
it('returns unhandled for unknown message types', () => {
const data = JSON.stringify({ type: 'unknown_type', data: {} });
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('unknown_type');
expect(result.handled).toBe(false);
});
it('handles invalid JSON gracefully', () => {
const data = 'not valid json {';
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('error');
expect(result.handled).toBe(false);
});
it('does not call handler when not provided', () => {
const data = JSON.stringify({
type: 'health',
data: { radio_connected: true },
});
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('health');
expect(result.handled).toBe(false);
});
it('routes raw_packet to onRawPacket handler', () => {
const onRawPacket = vi.fn();
const packetData = {
id: 1,
timestamp: 1700000000,
data: 'deadbeef',
payload_type: 'GROUP_TEXT',
decrypted: true,
decrypted_info: { channel_name: '#test', sender: 'Alice' },
};
const data = JSON.stringify({ type: 'raw_packet', data: packetData });
const result = parseWebSocketMessage(data, { onRawPacket });
expect(result.type).toBe('raw_packet');
expect(result.handled).toBe(true);
expect(onRawPacket).toHaveBeenCalledWith(packetData);
});
});
describe('useWebSocket ref-based handler pattern', () => {
/**
* These tests verify the pattern used in useWebSocket to avoid stale closures.
* The hook stores handlers in a ref and accesses them through the ref in callbacks.
* This ensures that when handlers are updated, the WebSocket still calls the latest version.
*/
it('demonstrates ref pattern prevents stale closure', () => {
// Simulate the ref pattern used in useWebSocket
interface Handlers {
onMessage?: (msg: string) => void;
}
// This simulates what the hook does: store handlers in a ref
const handlersRef: { current: Handlers } = { current: {} };
// First handler version
const firstHandler = vi.fn();
handlersRef.current = { onMessage: firstHandler };
// Simulate what onmessage does: access handlers through ref
const processMessage = (data: string) => {
// This is the pattern: access through ref.current, not closed-over variable
handlersRef.current.onMessage?.(data);
};
// Send first message
processMessage('message1');
expect(firstHandler).toHaveBeenCalledWith('message1');
// Update handler (simulates React re-render with new handler)
const secondHandler = vi.fn();
handlersRef.current = { onMessage: secondHandler };
// Send second message
processMessage('message2');
// First handler should NOT be called again (would happen with stale closure)
expect(firstHandler).toHaveBeenCalledTimes(1);
// Second handler should be called (ref pattern works)
expect(secondHandler).toHaveBeenCalledWith('message2');
});
it('demonstrates stale closure problem without ref pattern', () => {
// This demonstrates the bug we fixed - without refs, handlers become stale
interface Handlers {
onMessage?: (msg: string) => void;
}
// First handler version
const firstHandler = vi.fn();
let handlers: Handlers = { onMessage: firstHandler };
// BAD PATTERN: capture handlers in closure (this is what we fixed)
const capturedHandlers = handlers;
const processMessageBad = (data: string) => {
// This captures `capturedHandlers` at creation time - STALE!
capturedHandlers.onMessage?.(data);
};
// Send first message
processMessageBad('message1');
expect(firstHandler).toHaveBeenCalledWith('message1');
// Update handler
const secondHandler = vi.fn();
handlers = { onMessage: secondHandler };
// Send second message - BUG: still calls first handler!
processMessageBad('message2');
// This demonstrates the stale closure bug
expect(firstHandler).toHaveBeenCalledTimes(2); // Called twice - bug!
expect(secondHandler).not.toHaveBeenCalled(); // Never called - bug!
});
});