package main import ( "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gorilla/mux" _ "modernc.org/sqlite" ) // ─── 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) } } } // ─── Tests: buildNodeInfoMap observer enrichment (#753) ──────────────────────── func TestBuildNodeInfoMap_ObserverEnrichment(t *testing.T) { // Create a temp SQLite DB with nodes and observers tables. tmpDir := t.TempDir() dbPath := tmpDir + "/test.db" conn, err := sql.Open("sqlite", dbPath) if err != nil { t.Fatal(err) } defer conn.Close() // Create tables for _, stmt := range []string{ "CREATE TABLE nodes (public_key TEXT, name TEXT, role TEXT, lat REAL, lon REAL)", "CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)", "INSERT INTO nodes VALUES ('AAAA1111', 'Repeater-1', 'repeater', 0, 0)", "INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha', '')", "INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater', '')", } { if _, err := conn.Exec(stmt); err != nil { t.Fatalf("exec %q: %v", stmt, err) } } conn.Close() // Open via our DB wrapper db, err := OpenDB(dbPath) if err != nil { t.Fatal(err) } defer db.conn.Close() // Build a PacketStore with this DB (minimal — just need getCachedNodesAndPM) store := NewPacketStore(db, nil) store.Load() srv := &Server{ db: db, store: store, perfStats: NewPerfStats(), } m := srv.buildNodeInfoMap() // AAAA1111 should be from nodes table (repeater), NOT overwritten by observer if info, ok := m["aaaa1111"]; !ok { t.Error("expected aaaa1111 in map") } else if info.Role != "repeater" { t.Errorf("expected role=repeater for aaaa1111, got %q", info.Role) } // BBBB2222 should be enriched from observers table if info, ok := m["bbbb2222"]; !ok { t.Error("expected bbbb2222 in map (observer-only node)") } else { if info.Role != "observer" { t.Errorf("expected role=observer for bbbb2222, got %q", info.Role) } if info.Name != "Observer-Alpha" { t.Errorf("expected name=Observer-Alpha for bbbb2222, got %q", info.Name) } } } // TestBuildNodeInfoMap_FirstSeenIsCached asserts the regression introduced by // #1627 r3 stays fixed: the per-pubkey first_seen field MUST come from the // already-30s-cached getCachedNodesAndPM path, not from a fresh uncached // `SELECT … FROM nodes` scan on every call. // // Method (no DB-driver wrapper needed): mutate the underlying SQLite file's // first_seen via a separate rw connection between two consecutive calls to // buildNodeInfoMap(). If first_seen is read fresh on every call (the // regression), the second call sees the new value. If folded into the // existing 30s node cache, both calls return the original value — same as // every other nodeInfo field that comes from getAllNodes(). func TestBuildNodeInfoMap_FirstSeenIsCached(t *testing.T) { tmpDir := t.TempDir() dbPath := tmpDir + "/test.db" // Seed via rw connection. rw, err := sql.Open("sqlite", dbPath) if err != nil { t.Fatal(err) } defer rw.Close() for _, stmt := range []string{ "CREATE TABLE nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, advert_count INTEGER)", "CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)", "INSERT INTO nodes VALUES ('AAAA1111', 'Repeater-1', 'repeater', 0, 0, '', '2024-01-01T00:00:00Z', 0)", } { if _, err := rw.Exec(stmt); err != nil { t.Fatalf("seed exec %q: %v", stmt, err) } } db, err := OpenDB(dbPath) if err != nil { t.Fatal(err) } defer db.conn.Close() store := NewPacketStore(db, nil) store.Load() srv := &Server{ db: db, store: store, perfStats: NewPerfStats(), } // Call 1: warm cache and record observed first_seen. m1 := srv.buildNodeInfoMap() first1 := m1["aaaa1111"].FirstSeen if first1 != "2024-01-01T00:00:00Z" { t.Fatalf("setup: expected first_seen=2024-01-01T00:00:00Z, got %q", first1) } // Mutate first_seen out-of-band via the rw connection. Any code path // that re-reads first_seen from disk (uncached) will see this new // value; a path that folds first_seen into the 30s node cache will // not, because the cache is well under 30s old. if _, err := rw.Exec("UPDATE nodes SET first_seen='2099-12-31T23:59:59Z' WHERE public_key='AAAA1111'"); err != nil { t.Fatalf("mutate: %v", err) } // Call 2: should match call 1 if first_seen is cached. m2 := srv.buildNodeInfoMap() first2 := m2["aaaa1111"].FirstSeen if first2 != first1 { t.Errorf("buildNodeInfoMap re-scanned nodes.first_seen uncached (#1627 r3 regression): "+ "call 1 saw %q, call 2 saw %q after out-of-band UPDATE; expected both calls to return "+ "the cached value because getCachedNodesAndPM has a 30s TTL", first1, first2) } } // TestGetAllNodes_FirstSeenSchemaFallback exercises the schema-probe rung that // fires when nodes.first_seen is missing. The richest SELECT errors out, the // loop falls through to the next-richest query, and the resulting nodeInfo // values must have empty FirstSeen with no panic. Regression coverage for the // existing fallback branch (#1632 review loop 1). func TestGetAllNodes_FirstSeenSchemaFallback(t *testing.T) { tmpDir := t.TempDir() dbPath := tmpDir + "/test.db" // Seed a nodes table WITHOUT first_seen (advert_count + last_seen present). rw, err := sql.Open("sqlite", dbPath) if err != nil { t.Fatal(err) } defer rw.Close() for _, stmt := range []string{ "CREATE TABLE nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, advert_count INTEGER)", "CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)", "INSERT INTO nodes VALUES ('BBBB2222', 'Repeater-2', 'repeater', 0, 0, '2024-02-02T00:00:00Z', 3)", } { if _, err := rw.Exec(stmt); err != nil { t.Fatalf("seed exec %q: %v", stmt, err) } } db, err := OpenDB(dbPath) if err != nil { t.Fatal(err) } defer db.conn.Close() store := NewPacketStore(db, nil) nodes := store.getAllNodes() if len(nodes) != 1 { t.Fatalf("expected 1 row from fallback rung, got %d", len(nodes)) } n := nodes[0] if n.PublicKey != "BBBB2222" { t.Errorf("PublicKey mismatch: got %q", n.PublicKey) } if n.FirstSeen != "" { t.Errorf("FirstSeen should be empty when nodes.first_seen column is missing, got %q", n.FirstSeen) } if n.ObservationCount != 3 { t.Errorf("ObservationCount should still populate from advert_count fallback, got %d", n.ObservationCount) } }