Add route discovery

This commit is contained in:
Jack Kingsman
2026-03-13 17:53:23 -07:00
parent 3a4ea8022b
commit 1299a301c1
16 changed files with 678 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterLoginResponse,
@@ -153,6 +154,10 @@ export const api = {
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
method: 'POST',
}),
requestPathDiscovery: (publicKey: string) =>
fetchJson<PathDiscoveryResponse>(`/contacts/${publicKey}/path-discovery`, {
method: 'POST',
}),
setContactRoutingOverride: (publicKey: string, route: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, {
method: 'POST',

View File

@@ -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<PathDiscoveryResponse>;
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<HTMLSpanElement | null>(null);
useEffect(() => {
setShowKey(false);
setPathDiscoveryOpen(false);
}, [conversation.id]);
const activeChannel =
@@ -272,6 +284,21 @@ export function ChatHeader({
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPathDiscoveryOpen(true)}
title={
activeContactIsPrefixOnly
? 'Path Discovery unavailable until the full contact key is known'
: 'Path Discovery. Send a routed probe and inspect the forward and return paths'
}
aria-label="Path Discovery"
disabled={activeContactIsPrefixOnly}
>
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -279,7 +306,7 @@ export function ChatHeader({
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace'
: 'Direct Trace. Send a zero-hop packet to thie contact and display out and back SNR'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
@@ -371,6 +398,16 @@ export function ChatHeader({
</button>
)}
</div>
{conversation.type === 'contact' && activeContact && (
<ContactPathDiscoveryModal
open={pathDiscoveryOpen}
onClose={() => setPathDiscoveryOpen(false)}
contact={activeContact}
contacts={contacts}
radioName={config?.name ?? null}
onDiscover={onPathDiscovery}
/>
)}
</header>
);
}

View File

@@ -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<PathDiscoveryResponse>;
}
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 (
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold">{label}</h4>
<span className="text-[11px] text-muted-foreground">
{formatRouteLabel(route.path_len, true)}
</span>
</div>
<p className="mt-2 text-sm">{chain}</p>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
<span>Raw: {rawPath}</span>
<span>{formatPathHashMode(route.path_hash_mode)}</span>
</div>
</div>
);
}
export function ContactPathDiscoveryModal({
open,
onClose,
contact,
contacts,
radioName,
onDiscover,
}: ContactPathDiscoveryModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<PathDiscoveryResponse | null>(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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Path Discovery</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
<div className="font-medium">{contact.name || contact.public_key.slice(0, 12)}</div>
<div className="mt-1 text-muted-foreground">
Current learned route: {learnedRouteSummary}
</div>
{forcedRouteSummary && (
<div className="mt-1 text-destructive">
Current forced route: {forcedRouteSummary}
</div>
)}
</div>
{hasForcedRoute && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
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.
</div>
)}
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{result && forwardChain && returnChain && (
<div className="space-y-3">
<RouteCard label="Forward Path" route={result.forward_path} chain={forwardChain} />
<RouteCard label="Return Path" route={result.return_path} chain={returnChain} />
</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
<Button onClick={handleDiscover} disabled={loading}>
{loading ? 'Running...' : 'Run path discovery'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,6 +11,7 @@ import type {
Favorite,
HealthStatus,
Message,
PathDiscoveryResponse,
RawPacket,
RadioConfig,
} from '../types';
@@ -46,6 +47,7 @@ interface ConversationPaneProps {
loadingNewer: boolean;
messageInputRef: Ref<MessageInputHandle>;
onTrace: () => Promise<void>;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
onDeleteChannel: (key: string) => Promise<void>;
@@ -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}

View File

@@ -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<PathDiscoveryResponse>;
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'}
</Button>
)}
{contact && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPathDiscoveryOpen(true)}
title="Path Discovery. Send a routed probe and inspect the forward and return paths"
aria-label="Path Discovery"
>
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
@@ -184,6 +200,16 @@ export function RepeaterDashboard({
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{contact && (
<ContactPathDiscoveryModal
open={pathDiscoveryOpen}
onClose={() => setPathDiscoveryOpen(false)}
contact={contact}
contacts={contacts}
radioName={radioName}
onDiscover={onPathDiscovery}
/>
)}
</header>
{/* Body */}

View File

@@ -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<Conversation | null>;
setContacts: React.Dispatch<React.SetStateAction<Contact[]>>;
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
addMessageIfNew: (msg: Message) => boolean;
jumpToBottom: () => void;
@@ -25,6 +27,7 @@ interface UseConversationActionsResult {
) => Promise<void>;
handleSenderClick: (sender: string) => void;
handleTrace: () => Promise<void>;
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
handleBlockKey: (key: string) => Promise<void>;
handleBlockName: (name: string) => Promise<void>;
}
@@ -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,
};

View File

@@ -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', () => {

View File

@@ -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<PathDiscoveryResponse>,
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(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
contacts={[contact]}
onPathDiscovery={onPathDiscovery}
/>
);
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(
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
);
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);

View File

@@ -117,6 +117,9 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
onToggleFavorite: vi.fn(async () => {}),
onDeleteContact: vi.fn(async () => {}),
onDeleteChannel: vi.fn(async () => {}),

View File

@@ -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(),

View File

@@ -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<Parameters<typeof useConversationActions>
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]);
});
});

View File

@@ -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<string, number>;
mentions: Record<string, boolean>;

View File

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