mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 05:31:38 +00:00
078225a54e
## TL;DR Post-merge regression introduced by #1627 r3 (commit `e2212f50`): `buildNodeInfoMap` in `cmd/server/neighbor_api.go` ran an uncached `SELECT … FROM nodes` scan on every call. Folded `first_seen` into the already-cached `getCachedNodesAndPM` (30s TTL) so the 4 hot handlers that call `buildNodeInfoMap` no longer pay for a full table scan per request. ## Before / After `buildNodeInfoMap` is called by **4 hot handlers**: - `cmd/server/neighbor_api.go:130` - `cmd/server/neighbor_api.go:297` - `cmd/server/neighbor_debug.go:83` - `cmd/server/node_reach.go:421` | | Before | After | |---|---|---| | `SELECT … FROM nodes` per call | 1 (uncached) | 0 (cache hit) | | `SELECT … FROM observers` per call | 1 (uncached) | 1 (unchanged) | | At Cascadia scale (~2600 nodes) | full scan × 4 handlers × N req/s | one scan / 30s | ## How - Extended the `getAllNodes` schema probe to also `COALESCE(first_seen, '')`. Falls back through the existing richest → leanest ladder if the column is missing. - `nodeInfo.FirstSeen` is therefore populated for every cached entry in `getCachedNodesAndPM`. - `buildNodeInfoMap` drops its second `SELECT` entirely and just copies `nodeInfo` values out of the cached map. - Public signature of `buildNodeInfoMap` is unchanged. `node_reach.go:421` still sees `nodeInfo.FirstSeen` populated, served from cache. `cmd/server/store.go` is touched because `getAllNodes` is the only sensible owner of the `first_seen` SELECT — adding a parallel cache would duplicate the 30s TTL machinery this fix is designed to leverage. ## Test (red → green) - Commit 1 (`test:`): `TestBuildNodeInfoMap_FirstSeenIsCached` — calls `buildNodeInfoMap`, mutates `first_seen` out-of-band via a separate rw connection, calls it again, and asserts both calls return the same (cached) value. Fails on `origin/master` (call 2 sees the mutated value, proving the uncached scan). - Commit 2 (`perf:`): the fold. Test now passes. ## Refs Post-merge audit identified this as the only MAJOR finding from #1627; recommendation was a follow-up hot-fix PR. This is that PR. --------- Co-authored-by: openclaw-bot <bot@openclaw> Co-authored-by: openclaw-bot <bot@openclaw.local>
648 lines
20 KiB
Go
648 lines
20 KiB
Go
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)
|
|
}
|
|
}
|