Purge dead WS handlers from back when we loaded contacts + chans over WS not API

This commit is contained in:
Jack Kingsman
2026-02-12 00:36:24 -08:00
parent 7e7330eb12
commit 2248a13cde
6 changed files with 245 additions and 34 deletions

View File

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

View File

@@ -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`):

View File

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

View File

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

View File

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

View 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