Files
meshcore-analyzer/cmd/server/neighbor_api_test.go
T
efiten b587f20d1c feat: add distance column to neighbor table in node details (#617)
Closes #616

## What

Adds a **Distance** column to the neighbor table on the node detail
page.

When both the viewed node and a neighbor have GPS coordinates recorded,
the table shows the haversine distance between them (e.g. `3.2 km`).
When either node lacks GPS, the cell shows `—`.

## Changes

**Backend** (`cmd/server/neighbor_api.go`):
- Added `distance_km *float64` (omitempty) to `NeighborEntry`
- In `handleNodeNeighbors`: look up source node coords from `nodeMap`,
then for each resolved (non-ambiguous) neighbor with GPS, compute
`haversineKm` and set the field

**Frontend** (`public/nodes.js`):
- Added `Distance` column header between Last Seen and Conf
- Cell renders `X.X km` or `—` (muted) when unavailable

**Tests** (`cmd/server/neighbor_api_test.go`):
- `TestNeighborAPI_DistanceKm_WithGPS`: two nodes with real coords →
`distance_km` is positive
- `TestNeighborAPI_DistanceKm_NoGPS`: two nodes at 0,0 → `distance_km`
is nil

## Verification

Test at **https://staging.on8ar.eu** — navigate to any node detail page
and scroll to the Neighbors section. Nodes with GPS coordinates show a
distance; those without show `—`.
2026-04-05 12:33:23 -07:00

460 lines
14 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// ─── Helpers ───────────────────────────────────────────────────────────────────
// makeTestServer creates a Server with a pre-built neighbor graph for testing.
func makeTestServer(graph *NeighborGraph) *Server {
srv := &Server{
perfStats: NewPerfStats(),
}
srv.neighborGraph = graph
return srv
}
// makeTestGraph creates a graph with given edges for testing.
func makeTestGraph(edges ...*NeighborEdge) *NeighborGraph {
g := NewNeighborGraph()
g.mu.Lock()
for _, e := range edges {
key := makeEdgeKey(e.NodeA, e.NodeB)
if e.NodeB == "" {
key = makeEdgeKey(e.NodeA, "prefix:"+e.Prefix)
}
e.NodeA = key.A
if e.NodeB != "" {
e.NodeB = key.B
}
g.edges[key] = e
g.byNode[key.A] = append(g.byNode[key.A], e)
if key.B != "" && key.B != key.A {
g.byNode[key.B] = append(g.byNode[key.B], e)
}
}
g.builtAt = time.Now()
g.mu.Unlock()
return g
}
func newEdge(a, b, prefix string, count int, lastSeen time.Time) *NeighborEdge {
return &NeighborEdge{
NodeA: a,
NodeB: b,
Prefix: prefix,
Count: count,
FirstSeen: lastSeen.Add(-24 * time.Hour),
LastSeen: lastSeen,
Observers: map[string]bool{"obs1": true},
SNRSum: -8.0,
SNRCount: 1,
}
}
func newAmbiguousEdge(knownPK, prefix string, candidates []string, count int, lastSeen time.Time) *NeighborEdge {
return &NeighborEdge{
NodeA: knownPK,
NodeB: "",
Prefix: prefix,
Count: count,
FirstSeen: lastSeen.Add(-24 * time.Hour),
LastSeen: lastSeen,
Observers: map[string]bool{"obs1": true},
Ambiguous: true,
Candidates: candidates,
}
}
func serveRequest(srv *Server, method, path string) *httptest.ResponseRecorder {
router := mux.NewRouter()
router.HandleFunc("/api/nodes/{pubkey}/neighbors", srv.handleNodeNeighbors).Methods("GET")
router.HandleFunc("/api/analytics/neighbor-graph", srv.handleNeighborGraph).Methods("GET")
req := httptest.NewRequest(method, path, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// ─── Tests: /api/nodes/{pubkey}/neighbors ──────────────────────────────────────
func TestNeighborAPI_EmptyGraph(t *testing.T) {
srv := makeTestServer(makeTestGraph())
rr := serveRequest(srv, "GET", "/api/nodes/deadbeef/neighbors")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp NeighborResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad JSON: %v", err)
}
if resp.Node != "deadbeef" {
t.Errorf("node = %q, want deadbeef", resp.Node)
}
if len(resp.Neighbors) != 0 {
t.Errorf("expected 0 neighbors, got %d", len(resp.Neighbors))
}
if resp.TotalObservations != 0 {
t.Errorf("expected 0 observations, got %d", resp.TotalObservations)
}
}
func TestNeighborAPI_SingleNeighbor(t *testing.T) {
now := time.Now()
e := newEdge("aaaa", "bbbb", "bb", 50, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
n := resp.Neighbors[0]
if n.Pubkey == nil || *n.Pubkey != "bbbb" {
t.Errorf("expected pubkey bbbb, got %v", n.Pubkey)
}
if n.Count != 50 {
t.Errorf("expected count 50, got %d", n.Count)
}
if n.Score <= 0 {
t.Errorf("expected positive score, got %f", n.Score)
}
if n.Ambiguous {
t.Error("expected not ambiguous")
}
}
func TestNeighborAPI_MultipleNeighbors(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newEdge("aaaa", "cccc", "cc", 10, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 2 {
t.Fatalf("expected 2 neighbors, got %d", len(resp.Neighbors))
}
// Should be sorted by score descending.
if resp.Neighbors[0].Score < resp.Neighbors[1].Score {
t.Error("expected sorted by score descending")
}
if resp.TotalObservations != 110 {
t.Errorf("expected 110 total observations, got %d", resp.TotalObservations)
}
}
func TestNeighborAPI_AmbiguousCandidates(t *testing.T) {
now := time.Now()
e := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 12, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
n := resp.Neighbors[0]
if !n.Ambiguous {
t.Error("expected ambiguous")
}
if n.Pubkey != nil {
t.Errorf("expected nil pubkey for ambiguous, got %v", n.Pubkey)
}
if len(n.Candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(n.Candidates))
}
}
func TestNeighborAPI_UnresolvedPrefix(t *testing.T) {
now := time.Now()
e := newAmbiguousEdge("aaaa", "ff", []string{}, 3, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
n := resp.Neighbors[0]
if !n.Unresolved {
t.Error("expected unresolved=true")
}
if len(n.Candidates) != 0 {
t.Error("expected empty candidates for unresolved")
}
}
func TestNeighborAPI_MinCountFilter(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newEdge("aaaa", "cccc", "cc", 2, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_count=10")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor after min_count filter, got %d", len(resp.Neighbors))
}
if *resp.Neighbors[0].Pubkey != "bbbb" {
t.Error("expected bbbb to survive filter")
}
}
func TestNeighborAPI_MinScoreFilter(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // score ~1.0
e2 := newEdge("aaaa", "cccc", "cc", 1, now.Add(-30*24*time.Hour)) // very low score
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_score=0.5")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor after min_score filter, got %d", len(resp.Neighbors))
}
}
func TestNeighborAPI_ExcludeAmbiguous(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 50, now)
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01"}, 10, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?include_ambiguous=false")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 non-ambiguous neighbor, got %d", len(resp.Neighbors))
}
}
func TestNeighborAPI_UnknownNode(t *testing.T) {
now := time.Now()
e := newEdge("aaaa", "bbbb", "bb", 50, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/unknown1234/neighbors")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for unknown node, got %d", rr.Code)
}
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 0 {
t.Errorf("expected 0 neighbors for unknown node, got %d", len(resp.Neighbors))
}
}
// ─── Tests: /api/analytics/neighbor-graph ──────────────────────────────────────
func TestNeighborGraphAPI_EmptyGraph(t *testing.T) {
srv := makeTestServer(makeTestGraph())
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Edges) != 0 {
t.Errorf("expected 0 edges, got %d", len(resp.Edges))
}
if resp.Stats.TotalEdges != 0 {
t.Errorf("expected 0 total edges, got %d", resp.Stats.TotalEdges)
}
if resp.Stats.TotalNodes != 0 {
t.Errorf("expected 0 total nodes, got %d", resp.Stats.TotalNodes)
}
}
func TestNeighborGraphAPI_WithEdges(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newEdge("bbbb", "cccc", "cc", 50, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Edges) != 2 {
t.Fatalf("expected 2 edges, got %d", len(resp.Edges))
}
if resp.Stats.TotalNodes != 3 {
t.Errorf("expected 3 nodes, got %d", resp.Stats.TotalNodes)
}
if resp.Stats.TotalEdges != 2 {
t.Errorf("expected 2 total edges, got %d", resp.Stats.TotalEdges)
}
}
func TestNeighborGraphAPI_MinCountDefault(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // passes default min_count=5
e2 := newEdge("aaaa", "cccc", "cc", 2, now) // fails default min_count=5
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Edges) != 1 {
t.Fatalf("expected 1 edge with default min_count=5, got %d", len(resp.Edges))
}
}
func TestNeighborGraphAPI_AmbiguousEdgesCount(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 50, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.Stats.AmbiguousEdges != 1 {
t.Errorf("expected 1 ambiguous edge, got %d", resp.Stats.AmbiguousEdges)
}
}
func TestNeighborAPI_DistanceKm_WithGPS(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen)
VALUES ('aaaa', 'NodeA', 'repeater', 51.5074, -0.1278, '2026-01-01T00:00:00Z', '2025-01-01T00:00:00Z')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen)
VALUES ('bbbb', 'NodeB', 'repeater', 51.5200, -0.1200, '2026-01-01T00:00:00Z', '2025-01-01T00:00:00Z')`)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = NewPacketStore(db, nil)
now := time.Now()
srv.neighborGraph = makeTestGraph(newEdge("aaaa", "bbbb", "bb", 50, now))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
n := resp.Neighbors[0]
if n.DistanceKm == nil {
t.Fatal("expected distance_km to be set for GPS-enabled nodes")
}
if *n.DistanceKm <= 0 {
t.Errorf("expected positive distance, got %f", *n.DistanceKm)
}
}
func TestNeighborAPI_DistanceKm_NoGPS(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Nodes with 0,0 coords → HasGPS=false
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen)
VALUES ('aaaa', 'NodeA', 'repeater', 0, 0, '2026-01-01T00:00:00Z', '2025-01-01T00:00:00Z')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen)
VALUES ('bbbb', 'NodeB', 'repeater', 0, 0, '2026-01-01T00:00:00Z', '2025-01-01T00:00:00Z')`)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = NewPacketStore(db, nil)
now := time.Now()
srv.neighborGraph = makeTestGraph(newEdge("aaaa", "bbbb", "bb", 50, now))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
if resp.Neighbors[0].DistanceKm != nil {
t.Errorf("expected nil distance_km for nodes without GPS, got %f", *resp.Neighbors[0].DistanceKm)
}
}
func TestNeighborGraphAPI_RegionFilter(t *testing.T) {
now := time.Now()
// Edge with observer "obs-sjc" — would match region SJC if we had region resolution.
// Without a store, region filtering returns nothing (no observers match).
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
srv := makeTestServer(makeTestGraph(e1))
// No store → region filter has no observers → filters everything out.
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?region=SJC&min_count=1&min_score=0")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
// With no store, regionObs is nil so filter is skipped → all edges returned.
// Actually: region="" when store is nil → regionObs stays nil → no filtering.
// Wait, we set region=SJC and store is nil → resolveRegionObservers won't be called
// because s.store is nil. So regionObs is nil → filter not applied.
// Let's just check it doesn't crash.
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestNeighborGraphAPI_ResponseShape(t *testing.T) {
now := time.Now()
e := newEdge("aaaa", "bbbb", "bb", 100, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
var raw map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
t.Fatalf("bad JSON: %v", err)
}
// Verify top-level keys.
for _, key := range []string{"nodes", "edges", "stats"} {
if _, ok := raw[key]; !ok {
t.Errorf("missing key %q in response", key)
}
}
// Verify stats keys.
stats := raw["stats"].(map[string]interface{})
for _, key := range []string{"total_nodes", "total_edges", "ambiguous_edges", "avg_cluster_size"} {
if _, ok := stats[key]; !ok {
t.Errorf("missing stats key %q", key)
}
}
}