From 1299a301c1d22210b14511dc481401238f7575ae Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 13 Mar 2026 17:53:23 -0700 Subject: [PATCH] Add route discovery --- app/models.py | 24 ++ app/routers/contacts.py | 94 ++++++++ frontend/src/App.tsx | 3 + frontend/src/api.ts | 5 + frontend/src/components/ChatHeader.tsx | 43 +++- .../components/ContactPathDiscoveryModal.tsx | 213 ++++++++++++++++++ frontend/src/components/ConversationPane.tsx | 5 + frontend/src/components/RepeaterDashboard.tsx | 32 ++- frontend/src/hooks/useConversationActions.ts | 16 +- frontend/src/test/api.test.ts | 19 ++ .../src/test/chatHeaderKeyVisibility.test.tsx | 90 +++++++- frontend/src/test/conversationPane.test.tsx | 3 + frontend/src/test/repeaterDashboard.test.tsx | 3 + .../src/test/useConversationActions.test.ts | 48 +++- frontend/src/types.ts | 12 + tests/test_contacts_router.py | 78 +++++++ 16 files changed, 678 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/ContactPathDiscoveryModal.tsx diff --git a/app/models.py b/app/models.py index 95ef0d2..c34f67a 100644 --- a/app/models.py +++ b/app/models.py @@ -524,6 +524,30 @@ class TraceResponse(BaseModel): path_len: int = Field(description="Number of hops in the trace path") +class PathDiscoveryRoute(BaseModel): + """One resolved route returned by contact path discovery.""" + + path: str = Field(description="Hex-encoded path bytes") + path_len: int = Field(description="Hop count for this route") + path_hash_mode: int = Field( + description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hop identifiers)" + ) + + +class PathDiscoveryResponse(BaseModel): + """Round-trip routing data for a contact path discovery request.""" + + contact: Contact = Field( + description="Updated contact row after saving the learned forward path" + ) + forward_path: PathDiscoveryRoute = Field( + description="Route used from the local radio to the target contact" + ) + return_path: PathDiscoveryRoute = Field( + description="Route used from the target contact back to the local radio" + ) + + class CommandRequest(BaseModel): """Request to send a CLI command to a repeater.""" diff --git a/app/routers/contacts.py b/app/routers/contacts.py index bd56f5b..837ebc1 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -1,5 +1,7 @@ +import asyncio import logging import random +from contextlib import suppress from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from meshcore import EventType @@ -14,6 +16,8 @@ from app.models import ( ContactUpsert, CreateContactRequest, NearestRepeater, + PathDiscoveryResponse, + PathDiscoveryRoute, TraceResponse, ) from app.packet_processor import start_historical_dm_decryption @@ -106,6 +110,12 @@ async def _broadcast_contact_resolution(previous_public_keys: list[str], contact ) +def _path_hash_mode_from_hop_width(hop_width: object) -> int: + if not isinstance(hop_width, int): + return 0 + return max(0, min(hop_width - 1, 2)) + + async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics: name_history = await ContactNameHistoryRepository.get_history(contact.public_key) dm_count = await MessageRepository.count_dm_messages(contact.public_key) @@ -420,6 +430,90 @@ async def request_trace(public_key: str) -> TraceResponse: return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len) +@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse) +async def request_path_discovery(public_key: str) -> PathDiscoveryResponse: + """Discover the current forward and return paths to a known contact.""" + require_connected() + + contact = await _resolve_contact_or_404(public_key) + pubkey_prefix = contact.public_key[:12] + + async with radio_manager.radio_operation("request_path_discovery", pause_polling=True) as mc: + await _ensure_on_radio(mc, contact) + + response_task = asyncio.create_task( + mc.wait_for_event( + EventType.PATH_RESPONSE, + attribute_filters={"pubkey_pre": pubkey_prefix}, + timeout=15, + ) + ) + try: + result = await mc.commands.send_path_discovery(contact.public_key) + if result.type == EventType.ERROR: + raise HTTPException( + status_code=500, + detail=f"Failed to send path discovery: {result.payload}", + ) + + event = await response_task + finally: + if not response_task.done(): + response_task.cancel() + with suppress(asyncio.CancelledError): + await response_task + + if event is None: + raise HTTPException(status_code=504, detail="No path discovery response heard") + + payload = event.payload + forward_path = str(payload.get("out_path") or "") + forward_len = int(payload.get("out_path_len") or 0) + forward_mode = _path_hash_mode_from_hop_width(payload.get("out_path_hash_len")) + return_path = str(payload.get("in_path") or "") + return_len = int(payload.get("in_path_len") or 0) + return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len")) + + await ContactRepository.update_path( + contact.public_key, + forward_path, + forward_len, + forward_mode, + ) + refreshed_contact = await _resolve_contact_or_404(contact.public_key) + + try: + sync_result = await mc.commands.add_contact(refreshed_contact.to_radio_dict()) + if sync_result is not None and sync_result.type == EventType.ERROR: + logger.warning( + "Failed to sync discovered path back to radio for %s: %s", + refreshed_contact.public_key[:12], + sync_result.payload, + ) + except Exception: + logger.warning( + "Failed to sync discovered path back to radio for %s", + refreshed_contact.public_key[:12], + exc_info=True, + ) + + await _broadcast_contact_update(refreshed_contact) + + return PathDiscoveryResponse( + contact=refreshed_contact, + forward_path=PathDiscoveryRoute( + path=forward_path, + path_len=forward_len, + path_hash_mode=forward_mode, + ), + return_path=PathDiscoveryRoute( + path=return_path, + path_len=return_len, + path_hash_mode=return_mode, + ), + ) + + @router.post("/{public_key}/routing-override") async def set_contact_routing_override( public_key: str, request: ContactRoutingOverrideRequest diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3347f4..b91715d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -331,11 +331,13 @@ export function App() { handleSetChannelFloodScopeOverride, handleSenderClick, handleTrace, + handlePathDiscovery, handleBlockKey, handleBlockName, } = useConversationActions({ activeConversation, activeConversationRef, + setContacts, setChannels, addMessageIfNew, jumpToBottom, @@ -407,6 +409,7 @@ export function App() { loadingNewer, messageInputRef, onTrace: handleTrace, + onPathDiscovery: handlePathDiscovery, onToggleFavorite: handleToggleFavorite, onDeleteContact: handleDeleteContact, onDeleteChannel: handleDeleteChannel, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c9ef350..ef6422c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -19,6 +19,7 @@ import type { RadioConfigUpdate, RadioDiscoveryResponse, RadioDiscoveryTarget, + PathDiscoveryResponse, RepeaterAclResponse, RepeaterAdvertIntervalsResponse, RepeaterLoginResponse, @@ -153,6 +154,10 @@ export const api = { fetchJson(`/contacts/${publicKey}/trace`, { method: 'POST', }), + requestPathDiscovery: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/path-discovery`, { + method: 'POST', + }), setContactRoutingOverride: (publicKey: string, route: string) => fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, { method: 'POST', diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index d9e429f..1d5b247 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,14 +1,22 @@ import { useEffect, useRef, useState } from 'react'; -import { Bell, Globe2, Info, Star, Trash2 } from 'lucide-react'; +import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; import { toast } from './ui/sonner'; import { DirectTraceIcon } from './DirectTraceIcon'; +import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; import { stripRegionScopePrefix } from '../utils/regionScope'; import { isPrefixOnlyContact } from '../utils/pubkey'; import { ContactAvatar } from './ContactAvatar'; import { ContactStatusInfo } from './ContactStatusInfo'; -import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types'; +import type { + Channel, + Contact, + Conversation, + Favorite, + PathDiscoveryResponse, + RadioConfig, +} from '../types'; interface ChatHeaderProps { conversation: Conversation; @@ -20,6 +28,7 @@ interface ChatHeaderProps { notificationsEnabled: boolean; notificationsPermission: NotificationPermission | 'unsupported'; onTrace: () => void; + onPathDiscovery: (publicKey: string) => Promise; onToggleNotifications: () => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void; @@ -39,6 +48,7 @@ export function ChatHeader({ notificationsEnabled, notificationsPermission, onTrace, + onPathDiscovery, onToggleNotifications, onToggleFavorite, onSetChannelFloodScopeOverride, @@ -49,10 +59,12 @@ export function ChatHeader({ }: ChatHeaderProps) { const [showKey, setShowKey] = useState(false); const [contactStatusInline, setContactStatusInline] = useState(true); + const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const keyTextRef = useRef(null); useEffect(() => { setShowKey(false); + setPathDiscoveryOpen(false); }, [conversation.id]); const activeChannel = @@ -272,6 +284,21 @@ export function ChatHeader({
+ {conversation.type === 'contact' && ( + + )} {conversation.type === 'contact' && (
+ {conversation.type === 'contact' && activeContact && ( + setPathDiscoveryOpen(false)} + contact={activeContact} + contacts={contacts} + radioName={config?.name ?? null} + onDiscover={onPathDiscovery} + /> + )} ); } diff --git a/frontend/src/components/ContactPathDiscoveryModal.tsx b/frontend/src/components/ContactPathDiscoveryModal.tsx new file mode 100644 index 0000000..06f8001 --- /dev/null +++ b/frontend/src/components/ContactPathDiscoveryModal.tsx @@ -0,0 +1,213 @@ +import { useMemo, useState } from 'react'; + +import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../types'; +import { + findContactsByPrefix, + formatRouteLabel, + getEffectiveContactRoute, + hasRoutingOverride, + parsePathHops, +} from '../utils/pathUtils'; +import { Button } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; + +interface ContactPathDiscoveryModalProps { + open: boolean; + onClose: () => void; + contact: Contact; + contacts: Contact[]; + radioName: string | null; + onDiscover: (publicKey: string) => Promise; +} + +function formatPathHashMode(mode: number): string { + if (mode === 0) return '1-byte hops'; + if (mode === 1) return '2-byte hops'; + if (mode === 2) return '3-byte hops'; + return 'Unknown hop width'; +} + +function renderRouteNodes( + route: PathDiscoveryRoute, + startLabel: string, + endLabel: string, + contacts: Contact[] +): string { + if (route.path_len <= 0 || !route.path) { + return `${startLabel} -> ${endLabel}`; + } + + const hops = parsePathHops(route.path, route.path_len).map((prefix) => { + const matches = findContactsByPrefix(prefix, contacts, true); + if (matches.length === 1) { + return matches[0].name || `${matches[0].public_key.slice(0, prefix.length)}…`; + } + if (matches.length > 1) { + return `${prefix}…?`; + } + return `${prefix}…`; + }); + + return [startLabel, ...hops, endLabel].join(' -> '); +} + +function RouteCard({ + label, + route, + chain, +}: { + label: string; + route: PathDiscoveryRoute; + chain: string; +}) { + const rawPath = parsePathHops(route.path, route.path_len).join(' -> ') || 'direct'; + + return ( +
+
+

{label}

+ + {formatRouteLabel(route.path_len, true)} + +
+

{chain}

+
+ Raw: {rawPath} + {formatPathHashMode(route.path_hash_mode)} +
+
+ ); +} + +export function ContactPathDiscoveryModal({ + open, + onClose, + contact, + contacts, + radioName, + onDiscover, +}: ContactPathDiscoveryModalProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]); + const hasForcedRoute = hasRoutingOverride(contact); + const learnedRouteSummary = useMemo(() => { + if (contact.last_path_len === -1) { + return 'Flood'; + } + const hops = parsePathHops(contact.last_path, contact.last_path_len); + return hops.length > 0 + ? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})` + : formatRouteLabel(contact.last_path_len, true); + }, [contact.last_path, contact.last_path_len]); + const forcedRouteSummary = useMemo(() => { + if (!hasForcedRoute) { + return null; + } + if (effectiveRoute.pathLen === -1) { + return 'Flood'; + } + const hops = parsePathHops(effectiveRoute.path, effectiveRoute.pathLen); + return hops.length > 0 + ? `${formatRouteLabel(effectiveRoute.pathLen, true)} (${hops.join(' -> ')})` + : formatRouteLabel(effectiveRoute.pathLen, true); + }, [effectiveRoute, hasForcedRoute]); + + const forwardChain = result + ? renderRouteNodes( + result.forward_path, + radioName || 'Local radio', + contact.name || contact.public_key.slice(0, 12), + contacts + ) + : null; + const returnChain = result + ? renderRouteNodes( + result.return_path, + contact.name || contact.public_key.slice(0, 12), + radioName || 'Local radio', + contacts + ) + : null; + + const handleDiscover = async () => { + setLoading(true); + setError(null); + try { + const discovered = await onDiscover(contact.public_key); + setResult(discovered); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + return ( + !isOpen && onClose()}> + + + Path Discovery + + Send a routed probe to this contact and wait for the round-trip path response. The + learned forward route will be saved back onto the contact if a response comes back. + + + +
+
+
{contact.name || contact.public_key.slice(0, 12)}
+
+ Current learned route: {learnedRouteSummary} +
+ {forcedRouteSummary && ( +
+ Current forced route: {forcedRouteSummary} +
+ )} +
+ + {hasForcedRoute && ( +
+ A forced route override is currently set for this contact. Path discovery will update + the learned route data, but it will not replace the forced path. Clearing the forced + route afterward is enough to make the newly discovered learned path take effect. You + only need to rerun path discovery if you want a fresher route sample. +
+ )} + + {error && ( +
+ {error} +
+ )} + + {result && forwardChain && returnChain && ( +
+ + +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 42780d9..07b16f5 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -11,6 +11,7 @@ import type { Favorite, HealthStatus, Message, + PathDiscoveryResponse, RawPacket, RadioConfig, } from '../types'; @@ -46,6 +47,7 @@ interface ConversationPaneProps { loadingNewer: boolean; messageInputRef: Ref; onTrace: () => Promise; + onPathDiscovery: (publicKey: string) => Promise; onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; onDeleteContact: (publicKey: string) => Promise; onDeleteChannel: (key: string) => Promise; @@ -109,6 +111,7 @@ export function ConversationPane({ loadingNewer, messageInputRef, onTrace, + onPathDiscovery, onToggleFavorite, onDeleteContact, onDeleteChannel, @@ -205,6 +208,7 @@ export function ConversationPane({ radioLon={config?.lon ?? null} radioName={config?.name ?? null} onTrace={onTrace} + onPathDiscovery={onPathDiscovery} onToggleNotifications={onToggleNotifications} onToggleFavorite={onToggleFavorite} onDeleteContact={onDeleteContact} @@ -225,6 +229,7 @@ export function ConversationPane({ notificationsEnabled={notificationsEnabled} notificationsPermission={notificationsPermission} onTrace={onTrace} + onPathDiscovery={onPathDiscovery} onToggleNotifications={onToggleNotifications} onToggleFavorite={onToggleFavorite} onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride} diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index a70620e..ef3b434 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -1,13 +1,15 @@ +import { useState } from 'react'; + import { toast } from './ui/sonner'; import { Button } from './ui/button'; -import { Bell, Star, Trash2 } from 'lucide-react'; +import { Bell, Route, Star, Trash2 } from 'lucide-react'; import { DirectTraceIcon } from './DirectTraceIcon'; import { RepeaterLogin } from './RepeaterLogin'; import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; import { ContactStatusInfo } from './ContactStatusInfo'; -import type { Contact, Conversation, Favorite } from '../types'; +import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types'; import { TelemetryPane } from './repeater/RepeaterTelemetryPane'; import { NeighborsPane } from './repeater/RepeaterNeighborsPane'; import { AclPane } from './repeater/RepeaterAclPane'; @@ -17,6 +19,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane'; import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane'; import { ActionsPane } from './repeater/RepeaterActionsPane'; import { ConsolePane } from './repeater/RepeaterConsolePane'; +import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; // Re-export for backwards compatibility (used by repeaterFormatters.test.ts) export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'; @@ -34,6 +37,7 @@ interface RepeaterDashboardProps { radioLon: number | null; radioName: string | null; onTrace: () => void; + onPathDiscovery: (publicKey: string) => Promise; onToggleNotifications: () => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onDeleteContact: (publicKey: string) => void; @@ -48,12 +52,14 @@ export function RepeaterDashboard({ notificationsPermission, radioLat, radioLon, - radioName: _radioName, + radioName, onTrace, + onPathDiscovery, onToggleNotifications, onToggleFavorite, onDeleteContact, }: RepeaterDashboardProps) { + const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const { loggedIn, loginLoading, @@ -122,6 +128,16 @@ export function RepeaterDashboard({ {anyLoading ? 'Loading...' : 'Load All'} )} + {contact && ( + + )} + {contact && ( + setPathDiscoveryOpen(false)} + contact={contact} + contacts={contacts} + radioName={radioName} + onDiscover={onPathDiscovery} + /> + )} {/* Body */} diff --git a/frontend/src/hooks/useConversationActions.ts b/frontend/src/hooks/useConversationActions.ts index 0749b5a..fc74105 100644 --- a/frontend/src/hooks/useConversationActions.ts +++ b/frontend/src/hooks/useConversationActions.ts @@ -3,11 +3,13 @@ import { api } from '../api'; import * as messageCache from '../messageCache'; import { toast } from '../components/ui/sonner'; import type { MessageInputHandle } from '../components/MessageInput'; -import type { Channel, Conversation, Message } from '../types'; +import type { Channel, Contact, Conversation, Message, PathDiscoveryResponse } from '../types'; +import { mergeContactIntoList } from '../utils/contactMerge'; interface UseConversationActionsArgs { activeConversation: Conversation | null; activeConversationRef: MutableRefObject; + setContacts: React.Dispatch>; setChannels: React.Dispatch>; addMessageIfNew: (msg: Message) => boolean; jumpToBottom: () => void; @@ -25,6 +27,7 @@ interface UseConversationActionsResult { ) => Promise; handleSenderClick: (sender: string) => void; handleTrace: () => Promise; + handlePathDiscovery: (publicKey: string) => Promise; handleBlockKey: (key: string) => Promise; handleBlockName: (name: string) => Promise; } @@ -32,6 +35,7 @@ interface UseConversationActionsResult { export function useConversationActions({ activeConversation, activeConversationRef, + setContacts, setChannels, addMessageIfNew, jumpToBottom, @@ -126,6 +130,15 @@ export function useConversationActions({ } }, [activeConversation]); + const handlePathDiscovery = useCallback( + async (publicKey: string) => { + const result = await api.requestPathDiscovery(publicKey); + setContacts((prev) => mergeContactIntoList(prev, result.contact)); + return result; + }, + [setContacts] + ); + const handleBlockKey = useCallback( async (key: string) => { await handleToggleBlockedKey(key); @@ -150,6 +163,7 @@ export function useConversationActions({ handleSetChannelFloodScopeOverride, handleSenderClick, handleTrace, + handlePathDiscovery, handleBlockKey, handleBlockName, }; diff --git a/frontend/src/test/api.test.ts b/frontend/src/test/api.test.ts index 4e27f8d..0f54200 100644 --- a/frontend/src/test/api.test.ts +++ b/frontend/src/test/api.test.ts @@ -200,6 +200,25 @@ describe('fetchJson (via api methods)', () => { expect.objectContaining({ 'Content-Type': 'application/json' }) ); }); + + it('omits Content-Type on POST requests without a body', async () => { + installMockFetch(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + contact: null, + forward_path: { path: '', path_len: 0, path_hash_mode: 0 }, + return_path: { path: '', path_len: 0, path_hash_mode: 0 }, + }), + }); + + await api.requestPathDiscovery('aa'.repeat(32)); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(options.headers).not.toHaveProperty('Content-Type'); + }); }); describe('HTTP methods and body', () => { diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx index fa5b7b5..43b9769 100644 --- a/frontend/src/test/chatHeaderKeyVisibility.test.tsx +++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx @@ -1,8 +1,8 @@ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { ChatHeader } from '../components/ChatHeader'; -import type { Channel, Conversation, Favorite } from '../types'; +import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types'; function makeChannel(key: string, name: string, isHashtag: boolean): Channel { return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null }; @@ -18,6 +18,9 @@ const baseProps = { notificationsEnabled: false, notificationsPermission: 'granted' as const, onTrace: noop, + onPathDiscovery: vi.fn(async () => { + throw new Error('unused'); + }) as (_: string) => Promise, onToggleNotifications: noop, onToggleFavorite: noop, onSetChannelFloodScopeOverride: noop, @@ -166,6 +169,89 @@ describe('ChatHeader key visibility', () => { expect(onToggleNotifications).toHaveBeenCalledTimes(1); }); + it('opens path discovery modal for contacts and runs the request on demand', async () => { + const pubKey = '21'.repeat(32); + const contact: Contact = { + public_key: pubKey, + name: 'Alice', + type: 1, + flags: 0, + last_path: 'AA', + last_path_len: 1, + out_path_hash_mode: 0, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; + const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' }; + const onPathDiscovery = vi.fn().mockResolvedValue({ + contact, + forward_path: { path: 'AA', path_len: 1, path_hash_mode: 0 }, + return_path: { path: '', path_len: 0, path_hash_mode: 0 }, + } satisfies PathDiscoveryResponse); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Path Discovery' })); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Run path discovery' })); + + await waitFor(() => { + expect(onPathDiscovery).toHaveBeenCalledWith(pubKey); + }); + }); + + it('shows an override warning in the path discovery modal when forced routing is set', async () => { + const pubKey = '31'.repeat(32); + const contact: Contact = { + public_key: pubKey, + name: 'Alice', + type: 1, + flags: 0, + last_path: 'AA', + last_path_len: 1, + out_path_hash_mode: 0, + route_override_path: 'BBDD', + route_override_len: 2, + route_override_hash_mode: 0, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; + const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' }; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Path Discovery' })); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/current learned route: 1 hop \(AA\)/i)).toBeInTheDocument(); + expect(screen.getByText(/current forced route: 2 hops \(BB -> DD\)/i)).toBeInTheDocument(); + expect(screen.getByText(/forced route override is currently set/i)).toBeInTheDocument(); + expect(screen.getByText(/clearing the forced route afterward is enough/i)).toBeInTheDocument(); + }); + it('prompts for regional override when globe button is clicked', () => { const key = 'CD'.repeat(16); const channel = makeChannel(key, '#flightless', true); diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 5f421dd..ddc3b43 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -117,6 +117,9 @@ function createProps(overrides: Partial {}), + onPathDiscovery: vi.fn(async () => { + throw new Error('unused'); + }), onToggleFavorite: vi.fn(async () => {}), onDeleteContact: vi.fn(async () => {}), onDeleteChannel: vi.fn(async () => {}), diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 8b64b0a..86d9998 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -109,6 +109,9 @@ const defaultProps = { radioLon: null, radioName: null, onTrace: vi.fn(), + onPathDiscovery: vi.fn(async () => { + throw new Error('unused'); + }), onToggleNotifications: vi.fn(), onToggleFavorite: vi.fn(), onDeleteContact: vi.fn(), diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts index a9fb4e6..98e927e 100644 --- a/frontend/src/test/useConversationActions.test.ts +++ b/frontend/src/test/useConversationActions.test.ts @@ -2,10 +2,11 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useConversationActions } from '../hooks/useConversationActions'; -import type { Channel, Conversation, Message } from '../types'; +import type { Channel, Contact, Conversation, Message, PathDiscoveryResponse } from '../types'; const mocks = vi.hoisted(() => ({ api: { + requestPathDiscovery: vi.fn(), requestTrace: vi.fn(), resendChannelMessage: vi.fn(), sendChannelMessage: vi.fn(), @@ -65,6 +66,7 @@ function createArgs(overrides: Partial return { activeConversation, activeConversationRef: { current: activeConversation }, + setContacts: vi.fn(), setChannels: vi.fn(), addMessageIfNew: vi.fn(() => true), jumpToBottom: vi.fn(), @@ -143,4 +145,48 @@ describe('useConversationActions', () => { expect(args.messageInputRef.current?.appendText).toHaveBeenCalledWith('@[Alice] '); }); + + it('merges returned contact data after path discovery', async () => { + const contactKey = 'aa'.repeat(32); + const discoveredContact: Contact = { + public_key: contactKey, + name: 'Alice', + type: 1, + flags: 0, + last_path: 'AABB', + last_path_len: 2, + out_path_hash_mode: 0, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; + const response: PathDiscoveryResponse = { + contact: discoveredContact, + forward_path: { path: 'AABB', path_len: 2, path_hash_mode: 0 }, + return_path: { path: 'CC', path_len: 1, path_hash_mode: 0 }, + }; + mocks.api.requestPathDiscovery.mockResolvedValue(response); + const setContacts = vi.fn(); + const args = createArgs({ + activeConversation: { type: 'contact', id: contactKey, name: 'Alice' }, + activeConversationRef: { current: { type: 'contact', id: contactKey, name: 'Alice' } }, + setContacts, + }); + + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + await result.current.handlePathDiscovery(contactKey); + }); + + expect(mocks.api.requestPathDiscovery).toHaveBeenCalledWith(contactKey); + expect(setContacts).toHaveBeenCalledTimes(1); + const updater = setContacts.mock.calls[0][0] as (contacts: Contact[]) => Contact[]; + expect(updater([])).toEqual([discoveredContact]); + }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a1e4ea9..128dab7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -432,6 +432,18 @@ export interface TraceResponse { path_len: number; } +export interface PathDiscoveryRoute { + path: string; + path_len: number; + path_hash_mode: number; +} + +export interface PathDiscoveryResponse { + contact: Contact; + forward_path: PathDiscoveryRoute; + return_path: PathDiscoveryRoute; +} + export interface UnreadCounts { counts: Record; mentions: Record; diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 8440df0..3eb77d2 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -20,6 +20,13 @@ KEY_B = "bb" * 32 # bbbb...bb KEY_C = "cc" * 32 # cccc...cc +def _radio_result(event_type=EventType.OK, payload=None): + result = MagicMock() + result.type = event_type + result.payload = payload or {} + return result + + def _noop_radio_operation(mc=None): """Factory for a no-op radio_operation context manager that yields mc.""" @@ -255,6 +262,77 @@ class TestContactAnalytics: assert "exactly one" in response.json()["detail"].lower() +class TestPathDiscovery: + @pytest.mark.asyncio + async def test_updates_contact_route_and_broadcasts_contact(self, test_db, client): + await _insert_contact(KEY_A, "Alice", type=1) + mc = MagicMock() + mc.commands = MagicMock() + mc.commands.add_contact = AsyncMock(return_value=_radio_result()) + mc.commands.send_path_discovery = AsyncMock(return_value=_radio_result(EventType.MSG_SENT)) + mc.wait_for_event = AsyncMock( + return_value=MagicMock( + payload={ + "pubkey_pre": KEY_A[:12], + "out_path": "11223344", + "out_path_len": 2, + "out_path_hash_len": 2, + "in_path": "778899", + "in_path_len": 1, + "in_path_hash_len": 3, + } + ) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch("app.routers.contacts.radio_manager") as mock_rm, + patch("app.websocket.broadcast_event") as mock_broadcast, + ): + mock_rm.radio_operation = _noop_radio_operation(mc) + response = await client.post(f"/api/contacts/{KEY_A}/path-discovery") + + assert response.status_code == 200 + data = response.json() + assert data["forward_path"] == { + "path": "11223344", + "path_len": 2, + "path_hash_mode": 1, + } + assert data["return_path"] == { + "path": "778899", + "path_len": 1, + "path_hash_mode": 2, + } + + updated = await ContactRepository.get_by_key(KEY_A) + assert updated is not None + assert updated.last_path == "11223344" + assert updated.last_path_len == 2 + assert updated.out_path_hash_mode == 1 + mc.commands.add_contact.assert_awaited() + mock_broadcast.assert_called_once_with("contact", updated.model_dump()) + + @pytest.mark.asyncio + async def test_returns_504_when_no_response_is_heard(self, test_db, client): + await _insert_contact(KEY_A, "Alice", type=1) + mc = MagicMock() + mc.commands = MagicMock() + mc.commands.add_contact = AsyncMock(return_value=_radio_result()) + mc.commands.send_path_discovery = AsyncMock(return_value=_radio_result(EventType.MSG_SENT)) + mc.wait_for_event = AsyncMock(return_value=None) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch("app.routers.contacts.radio_manager") as mock_rm, + ): + mock_rm.radio_operation = _noop_radio_operation(mc) + response = await client.post(f"/api/contacts/{KEY_A}/path-discovery") + + assert response.status_code == 504 + assert "No path discovery response heard" in response.json()["detail"] + + class TestDeleteContactCascade: """Test that contact delete cleans up related tables."""