mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
273 lines
8.7 KiB
TypeScript
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!
|
|
});
|
|
});
|