mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Purge dead WS handlers from back when we loaded contacts + chans over WS not API
This commit is contained in:
@@ -142,16 +142,16 @@ app/
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
- `health`
|
||||
- `contact`
|
||||
- `message`
|
||||
- `message_acked`
|
||||
- `raw_packet`
|
||||
- `error`
|
||||
- `success`
|
||||
- `health` — radio connection status (broadcast on change, personal on connect)
|
||||
- `contact` — single contact upsert (from advertisements and radio sync)
|
||||
- `message` — new message (channel or DM, from packet processor or send endpoints)
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
- `error` — toast notification (reconnect failure, missing private key, etc.)
|
||||
- `success` — toast notification (historical decrypt complete, etc.)
|
||||
|
||||
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
Note: the frontend WS hook also registers handlers for `contacts` and `channels` events, but the backend never emits them.
|
||||
Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
|
||||
## Data Model Notes
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ Specialized logic is delegated to hooks:
|
||||
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Event handlers: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored).
|
||||
|
||||
## URL Hash Navigation (`utils/urlHash.ts`)
|
||||
|
||||
@@ -156,10 +156,9 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
|
||||
Backend also tracks `last_advert_time` in settings responses.
|
||||
|
||||
## Repeater Mode
|
||||
|
||||
For repeater contacts (`type=2`):
|
||||
|
||||
@@ -179,11 +179,6 @@ export function App() {
|
||||
description: success.details,
|
||||
});
|
||||
},
|
||||
onContacts: (data: Contact[]) => {
|
||||
setContacts(data);
|
||||
setContactsLoaded(true);
|
||||
},
|
||||
onChannels: (data: Channel[]) => setChannels(data),
|
||||
onMessage: (msg: Message) => {
|
||||
const activeConv = activeConversationRef.current;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { HealthStatus, Contact, Channel, Message, MessagePath, RawPacket } from '../types';
|
||||
import type { HealthStatus, Contact, Message, MessagePath, RawPacket } from '../types';
|
||||
|
||||
/**
|
||||
* Parse and route a WebSocket message.
|
||||
@@ -16,8 +16,6 @@ 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;
|
||||
@@ -31,12 +29,6 @@ function parseWebSocketMessage(
|
||||
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 };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type { HealthStatus, Contact, Channel, Message, MessagePath, RawPacket } from './types';
|
||||
import type { HealthStatus, Contact, Message, MessagePath, RawPacket } from './types';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
@@ -18,8 +18,6 @@ interface SuccessEvent {
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onContacts?: (contacts: Contact[]) => void;
|
||||
onChannels?: (channels: Channel[]) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
@@ -100,12 +98,6 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
case 'health':
|
||||
handlers.onHealth?.(msg.data as HealthStatus);
|
||||
break;
|
||||
case 'contacts':
|
||||
handlers.onContacts?.(msg.data as Contact[]);
|
||||
break;
|
||||
case 'channels':
|
||||
handlers.onChannels?.(msg.data as Channel[]);
|
||||
break;
|
||||
case 'message':
|
||||
handlers.onMessage?.(msg.data as Message);
|
||||
break;
|
||||
|
||||
233
tests/test_websocket_route.py
Normal file
233
tests/test_websocket_route.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Tests for the WebSocket route endpoint (/api/ws).
|
||||
|
||||
These integration tests verify the WebSocket endpoint behavior:
|
||||
- Initial health message sent on connect
|
||||
- Ping/pong keepalive mechanism
|
||||
- Clean disconnect handling
|
||||
|
||||
Uses FastAPI's TestClient synchronous WebSocket support with mocked
|
||||
radio_manager and health data dependencies.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.websocket import ws_manager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_ws_manager():
|
||||
"""Ensure ws_manager has no stale connections between tests."""
|
||||
ws_manager.active_connections.clear()
|
||||
yield
|
||||
ws_manager.active_connections.clear()
|
||||
|
||||
|
||||
class TestWebSocketEndpoint:
|
||||
"""Tests for the /api/ws WebSocket endpoint."""
|
||||
|
||||
def test_receives_initial_health_on_connect(self):
|
||||
"""Client receives a health event with radio status immediately after connecting."""
|
||||
with (
|
||||
patch("app.routers.ws.radio_manager") as mock_ws_rm,
|
||||
patch("app.routers.health.radio_manager") as mock_health_rm,
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
patch("app.routers.health.settings") as mock_settings,
|
||||
patch("app.routers.health.os.path.getsize", return_value=1024 * 1024),
|
||||
):
|
||||
mock_ws_rm.is_connected = True
|
||||
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_health_rm.is_connected = True
|
||||
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
mock_settings.database_path = "/tmp/test.db"
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
data = ws.receive_json()
|
||||
|
||||
assert data["type"] == "health"
|
||||
assert "data" in data
|
||||
|
||||
health = data["data"]
|
||||
assert health["radio_connected"] is True
|
||||
assert health["connection_info"] == "Serial: /dev/ttyUSB0"
|
||||
assert health["status"] == "ok"
|
||||
assert "database_size_mb" in health
|
||||
assert "oldest_undecrypted_timestamp" in health
|
||||
|
||||
def test_initial_health_reflects_disconnected_radio(self):
|
||||
"""Health event reflects degraded status when radio is not connected."""
|
||||
with (
|
||||
patch("app.routers.ws.radio_manager") as mock_ws_rm,
|
||||
patch("app.routers.health.radio_manager") as mock_health_rm,
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
patch("app.routers.health.settings") as mock_settings,
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
):
|
||||
mock_ws_rm.is_connected = False
|
||||
mock_ws_rm.connection_info = None
|
||||
mock_health_rm.is_connected = False
|
||||
mock_health_rm.connection_info = None
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
mock_settings.database_path = "/tmp/test.db"
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
data = ws.receive_json()
|
||||
|
||||
assert data["type"] == "health"
|
||||
health = data["data"]
|
||||
assert health["radio_connected"] is False
|
||||
assert health["connection_info"] is None
|
||||
assert health["status"] == "degraded"
|
||||
|
||||
def test_ping_returns_pong(self):
|
||||
"""Sending 'ping' text receives a JSON pong response."""
|
||||
with (
|
||||
patch("app.routers.ws.radio_manager") as mock_ws_rm,
|
||||
patch("app.routers.health.radio_manager") as mock_health_rm,
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
patch("app.routers.health.settings") as mock_settings,
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
):
|
||||
mock_ws_rm.is_connected = True
|
||||
mock_ws_rm.connection_info = "TCP: 192.168.1.1:4000"
|
||||
mock_health_rm.is_connected = True
|
||||
mock_health_rm.connection_info = "TCP: 192.168.1.1:4000"
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
mock_settings.database_path = "/tmp/test.db"
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
# Consume the initial health message
|
||||
ws.receive_json()
|
||||
|
||||
# Send ping and verify pong
|
||||
ws.send_text("ping")
|
||||
pong = ws.receive_json()
|
||||
|
||||
assert pong == {"type": "pong"}
|
||||
|
||||
def test_multiple_pings_return_multiple_pongs(self):
|
||||
"""Each ping gets its own pong response."""
|
||||
with (
|
||||
patch("app.routers.ws.radio_manager") as mock_ws_rm,
|
||||
patch("app.routers.health.radio_manager") as mock_health_rm,
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
patch("app.routers.health.settings") as mock_settings,
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
):
|
||||
mock_ws_rm.is_connected = True
|
||||
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_health_rm.is_connected = True
|
||||
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
mock_settings.database_path = "/tmp/test.db"
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
ws.receive_json() # consume health
|
||||
|
||||
for _ in range(3):
|
||||
ws.send_text("ping")
|
||||
pong = ws.receive_json()
|
||||
assert pong == {"type": "pong"}
|
||||
|
||||
def test_non_ping_message_does_not_produce_response(self):
|
||||
"""Messages other than 'ping' are silently ignored (no response sent)."""
|
||||
with (
|
||||
patch("app.routers.ws.radio_manager") as mock_ws_rm,
|
||||
patch("app.routers.health.radio_manager") as mock_health_rm,
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
patch("app.routers.health.settings") as mock_settings,
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
):
|
||||
mock_ws_rm.is_connected = True
|
||||
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_health_rm.is_connected = True
|
||||
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
mock_settings.database_path = "/tmp/test.db"
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
ws.receive_json() # consume health
|
||||
|
||||
# Send a non-ping message, then a ping to verify the connection
|
||||
# is still alive and only the ping produces a response
|
||||
ws.send_text("hello")
|
||||
ws.send_text("ping")
|
||||
pong = ws.receive_json()
|
||||
assert pong == {"type": "pong"}
|
||||
|
||||
def test_disconnect_removes_client_from_manager(self):
|
||||
"""Closing the WebSocket removes the connection from ws_manager."""
|
||||
with (
|
||||
patch("app.routers.ws.radio_manager") as mock_ws_rm,
|
||||
patch("app.routers.health.radio_manager") as mock_health_rm,
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
patch("app.routers.health.settings") as mock_settings,
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
):
|
||||
mock_ws_rm.is_connected = True
|
||||
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_health_rm.is_connected = True
|
||||
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
mock_settings.database_path = "/tmp/test.db"
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
ws.receive_json() # consume health
|
||||
assert len(ws_manager.active_connections) == 1
|
||||
|
||||
# After context manager exits, the WebSocket is closed
|
||||
assert len(ws_manager.active_connections) == 0
|
||||
|
||||
def test_disconnect_is_clean_no_error(self):
|
||||
"""Normal client disconnect does not raise or leave dangling state."""
|
||||
with (
|
||||
patch("app.routers.ws.radio_manager") as mock_ws_rm,
|
||||
patch("app.routers.health.radio_manager") as mock_health_rm,
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
patch("app.routers.health.settings") as mock_settings,
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
):
|
||||
mock_ws_rm.is_connected = False
|
||||
mock_ws_rm.connection_info = None
|
||||
mock_health_rm.is_connected = False
|
||||
mock_health_rm.connection_info = None
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
mock_settings.database_path = "/tmp/test.db"
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Connect and immediately disconnect -- should not raise
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
ws.receive_json() # consume health
|
||||
|
||||
# Verify clean state
|
||||
assert len(ws_manager.active_connections) == 0
|
||||
Reference in New Issue
Block a user