From 2248a13cde9456782b4861949a03bc3272e5e09b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 12 Feb 2026 00:36:24 -0800 Subject: [PATCH] Purge dead WS handlers from back when we loaded contacts + chans over WS not API --- app/AGENTS.md | 16 +- frontend/AGENTS.md | 5 +- frontend/src/App.tsx | 5 - frontend/src/test/websocket.test.ts | 10 +- frontend/src/useWebSocket.ts | 10 +- tests/test_websocket_route.py | 233 ++++++++++++++++++++++++++++ 6 files changed, 245 insertions(+), 34 deletions(-) create mode 100644 tests/test_websocket_route.py diff --git a/app/AGENTS.md b/app/AGENTS.md index c6df424..8b78a77 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index da0f1ba..59ef669 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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`): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a82382..bc000c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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; diff --git a/frontend/src/test/websocket.test.ts b/frontend/src/test/websocket.test.ts index caf9fa9..e48635f 100644 --- a/frontend/src/test/websocket.test.ts +++ b/frontend/src/test/websocket.test.ts @@ -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 }; diff --git a/frontend/src/useWebSocket.ts b/frontend/src/useWebSocket.ts index 6296278..4e3bc61 100644 --- a/frontend/src/useWebSocket.ts +++ b/frontend/src/useWebSocket.ts @@ -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; diff --git a/tests/test_websocket_route.py b/tests/test_websocket_route.py new file mode 100644 index 0000000..0295842 --- /dev/null +++ b/tests/test_websocket_route.py @@ -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