mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-12 22:55:54 +00:00
## Summary Replace N+1 per-hop DB queries in `handleResolveHops` with O(1) lookups against the in-memory prefix map that already exists in the packet store. ## Problem Each hop in the `resolve-hops` API triggered a separate `SELECT ... LIKE ?` query against the nodes table. With 10 hops, that's 10 DB round-trips — unnecessary when `getCachedNodesAndPM()` already maintains an in-memory prefix map that can resolve hops instantly. ## Changes - **routes.go**: Replace the per-hop DB query loop with `pm.m[hopLower]` lookups from the prefix map. Convert `nodeInfo` → `HopCandidate` inline. Remove unused `rows`/`sql.Scan` code. - **store.go**: Add `InvalidateNodeCache()` method to force prefix map rebuild (needed by tests that insert nodes after store initialization). - **routes_test.go**: Give `TestResolveHopsAmbiguous` a proper store so hops resolve via the prefix map. - **resolve_context_test.go**: Call `InvalidateNodeCache()` after inserting test nodes. Fix confidence assertion — with GPS candidates and no affinity context, `resolveWithContext` correctly returns `gps_preference` (previously masked because the prefix map didn't have the test nodes). ## Complexity O(1) per hop lookup via hash map vs O(n) DB scan per hop. No hot-path impact — this endpoint is called on-demand, not in a render loop. Fixes #369 --------- Co-authored-by: you <you@example.com>
310 lines
9.5 KiB
Go
310 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ─── resolveWithContext unit tests ─────────────────────────────────────────────
|
|
|
|
func TestResolveWithContext_UniquePrefix(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1b2c3d4", Name: "Node-A", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
ni, confidence, _ := pm.resolveWithContext("a1b2c3d4", nil, nil)
|
|
if ni == nil || ni.Name != "Node-A" {
|
|
t.Fatal("expected Node-A")
|
|
}
|
|
if confidence != "unique_prefix" {
|
|
t.Fatalf("expected unique_prefix, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NoMatch(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1b2c3d4", Name: "Node-A"},
|
|
})
|
|
ni, confidence, _ := pm.resolveWithContext("ff", nil, nil)
|
|
if ni != nil {
|
|
t.Fatal("expected nil")
|
|
}
|
|
if confidence != "no_match" {
|
|
t.Fatalf("expected no_match, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_AffinityWins(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1aaaaaa", Name: "Node-A1"},
|
|
{PublicKey: "a1bbbbbb", Name: "Node-A2"},
|
|
})
|
|
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 100; i++ {
|
|
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
|
}
|
|
|
|
ni, confidence, score := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
|
if ni == nil || ni.Name != "Node-A1" {
|
|
t.Fatalf("expected Node-A1, got %v", ni)
|
|
}
|
|
if confidence != "neighbor_affinity" {
|
|
t.Fatalf("expected neighbor_affinity, got %s", confidence)
|
|
}
|
|
if score <= 0 {
|
|
t.Fatalf("expected positive score, got %f", score)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_AffinityTooClose_FallsToGeo(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1aaaaaa", Name: "Node-A1", HasGPS: true, Lat: 10, Lon: 20},
|
|
{PublicKey: "a1bbbbbb", Name: "Node-A2", HasGPS: true, Lat: 11, Lon: 21},
|
|
{PublicKey: "c0c0c0c0", Name: "Ctx", HasGPS: true, Lat: 10.1, Lon: 20.1},
|
|
})
|
|
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 50; i++ {
|
|
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
|
graph.upsertEdge("c0c0c0c0", "a1bbbbbb", "a1", "obs1", nil, time.Now())
|
|
}
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
|
if ni == nil {
|
|
t.Fatal("expected a result")
|
|
}
|
|
if confidence != "geo_proximity" {
|
|
t.Fatalf("expected geo_proximity, got %s", confidence)
|
|
}
|
|
if ni.Name != "Node-A1" {
|
|
t.Fatalf("expected Node-A1 (closer to context), got %s", ni.Name)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_GPSPreference(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS, got %v", ni)
|
|
}
|
|
if confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_FirstMatchFallback(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1aaaaaa", Name: "First"},
|
|
{PublicKey: "a1bbbbbb", Name: "Second"},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
|
if ni == nil || ni.Name != "First" {
|
|
t.Fatalf("expected First, got %v", ni)
|
|
}
|
|
if confidence != "first_match" {
|
|
t.Fatalf("expected first_match, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NilGraphFallsToGPS(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", []string{"someone"}, nil)
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS, got %v", ni)
|
|
}
|
|
if confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_BackwardCompatResolve(t *testing.T) {
|
|
// Verify original resolve() still works unchanged
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
ni := pm.resolve("a1")
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS from resolve(), got %v", ni)
|
|
}
|
|
}
|
|
|
|
// ─── geoDistApprox ─────────────────────────────────────────────────────────────
|
|
|
|
func TestGeoDistApprox_SamePoint(t *testing.T) {
|
|
d := geoDistApprox(37.0, -122.0, 37.0, -122.0)
|
|
if d != 0 {
|
|
t.Fatalf("expected 0, got %f", d)
|
|
}
|
|
}
|
|
|
|
func TestGeoDistApprox_Ordering(t *testing.T) {
|
|
d1 := geoDistApprox(37.0, -122.0, 37.01, -122.01)
|
|
d2 := geoDistApprox(37.0, -122.0, 38.0, -121.0)
|
|
if d1 >= d2 {
|
|
t.Fatal("closer point should have smaller distance")
|
|
}
|
|
}
|
|
|
|
// ─── handleResolveHops enhanced response (API tests) ───────────────────────────
|
|
|
|
func TestResolveHopsAPI_UniquePrefix(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
_ = srv
|
|
|
|
// Insert a unique node
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
|
"ff11223344", "UniqueNode", 37.0, -122.0)
|
|
srv.store.InvalidateNodeCache()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("bad JSON: %v", err)
|
|
}
|
|
|
|
hr, ok := result.Resolved["ff11223344"]
|
|
if !ok {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
if hr.Confidence != "unique_prefix" {
|
|
t.Fatalf("expected unique_prefix, got %s", hr.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
|
"ee1aaaaaaa", "Node-E1", 37.0, -122.0)
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
|
"ee1bbbbbbb", "Node-E2", 38.0, -121.0)
|
|
srv.store.InvalidateNodeCache()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
json.Unmarshal(rr.Body.Bytes(), &result)
|
|
|
|
hr := result.Resolved["ee1"]
|
|
if hr == nil {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
// With both candidates having GPS and no affinity context, the resolver
|
|
// picks the GPS-preferred candidate → confidence is "gps_preference".
|
|
if hr.Confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", hr.Confidence)
|
|
}
|
|
if len(hr.Candidates) != 2 {
|
|
t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates))
|
|
}
|
|
for _, c := range hr.Candidates {
|
|
if c.AffinityScore != nil {
|
|
t.Fatal("expected nil affinity score without context")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_WithAffinityContext(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
|
"dd1aaaaaaa", "Node-D1", 37.0, -122.0)
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
|
"dd1bbbbbbb", "Node-D2", 38.0, -121.0)
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
|
"c0c0c0c0c0", "Context", 37.1, -122.1)
|
|
|
|
// Invalidate node cache so the PM includes newly inserted nodes.
|
|
srv.store.cacheMu.Lock()
|
|
srv.store.nodeCacheTime = time.Time{}
|
|
srv.store.cacheMu.Unlock()
|
|
|
|
// Build graph with strong affinity
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 100; i++ {
|
|
graph.upsertEdge("c0c0c0c0c0", "dd1aaaaaaa", "dd1", "obs1", nil, time.Now())
|
|
}
|
|
graph.builtAt = time.Now()
|
|
srv.neighborMu.Lock()
|
|
srv.neighborGraph = graph
|
|
srv.neighborMu.Unlock()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=dd1&from_node=c0c0c0c0c0", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
json.Unmarshal(rr.Body.Bytes(), &result)
|
|
|
|
hr := result.Resolved["dd1"]
|
|
if hr == nil {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
if hr.Confidence != "neighbor_affinity" {
|
|
t.Fatalf("expected neighbor_affinity, got %s", hr.Confidence)
|
|
}
|
|
if hr.BestCandidate == nil || *hr.BestCandidate != "dd1aaaaaaa" {
|
|
t.Fatalf("expected bestCandidate dd1aaaaaaa, got %v", hr.BestCandidate)
|
|
}
|
|
|
|
// Verify affinity scores present
|
|
hasScore := false
|
|
for _, c := range hr.Candidates {
|
|
if c.AffinityScore != nil && *c.AffinityScore > 0 {
|
|
hasScore = true
|
|
}
|
|
}
|
|
if !hasScore {
|
|
t.Fatal("expected at least one candidate with affinity score")
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_ResponseShape(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
|
"bb1aaaaaaa", "Node-B1", 37.0, -122.0)
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=bb1a", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var raw map[string]json.RawMessage
|
|
json.Unmarshal(rr.Body.Bytes(), &raw)
|
|
|
|
if _, ok := raw["resolved"]; !ok {
|
|
t.Fatal("missing 'resolved' key")
|
|
}
|
|
|
|
var resolved map[string]map[string]interface{}
|
|
json.Unmarshal(raw["resolved"], &resolved)
|
|
|
|
for _, hr := range resolved {
|
|
if _, ok := hr["confidence"]; !ok {
|
|
t.Error("missing 'confidence' field in HopResolution")
|
|
}
|
|
if _, ok := hr["candidates"]; !ok {
|
|
t.Error("missing 'candidates' field")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Helpers used only in this test file ───────────────────────────────────────
|