Files
meshcore-analyzer/cmd/server/hop_depth_analytics_test.go
T
dborupandClaude Sonnet 5 e23e632d54 feat: scoped-vs-unscoped median hop time-series trend on Hop Depth tab
GetHopDepthAnalytics now buckets its scoped/unscoped hop-index tallies by
time (same 5min/1h/6h bucketing as GetScopeStats' TimeSeries) and computes
a per-bucket median for each series, exposed as a new TimeSeries field.
Median is a *int, nil when a bucket had no traffic of that kind -- 0 is a
valid median hop, so absence has to stay distinguishable from zero.

Frontend renders it as a two-line SVG trend chart on the Scopes > Hop
Depth sub-tab (same visual language as Overview's scoped/unscoped
time-series), answering "is containment getting better or worse over the
window" rather than just a single window-wide snapshot. Each series'
polyline is built from contiguous non-null segments so a gap renders as
a visible break instead of silently interpolating through missing data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 09:50:51 +02:00

265 lines
11 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestGetHopDepthAnalytics covers the two questions HopDepthAnalyticsResponse
// answers: (1) does scoped traffic actually travel fewer hops than unscoped
// traffic network-wide, and (2) which repeaters relay unscoped flood traffic
// that has already traveled far (high hops) vs merely locally (low hops).
func TestGetHopDepthAnalytics(t *testing.T) {
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
defer conn.Close()
conn.SetMaxOpenConns(1)
db := &DB{conn: conn}
if _, err := conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`); err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER
)`); err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(`CREATE TABLE observations (
id INTEGER PRIMARY KEY, transmission_id INTEGER, resolved_path TEXT
)`); err != nil {
t.Fatal(err)
}
recent := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339)
repeaterA := "aa001111aaaabbbb"
repeaterB := "bb001111bbbbcccc"
origin := "cc001111ccccdddd"
conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'RepeaterA', 'repeater')`, repeaterA)
conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'RepeaterB', 'repeater')`, repeaterB)
conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'CompanionC', 'companion')`, origin)
// tx1: TRANSPORT_FLOOD (scoped), path len 1 -> scoped bucket gets one
// hop=0 entry. Not unscoped, so doesn't feed unscopedByRepeater.
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (1, 'h1', ?, 0, 5)`, recent)
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (1, 1, ?)`, `["`+repeaterA+`"]`)
// tx2: plain FLOOD (unscoped), not advert, path len 2 -> unscoped
// bucket gets hop=0 (repeaterA) and hop=1 (repeaterB). Both feed
// unscopedByRepeater: repeaterA at hop 0, repeaterB at hop 1.
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (2, 'h2', ?, 1, 5)`, recent)
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (2, 2, ?)`, `["`+repeaterA+`","`+repeaterB+`"]`)
// tx3: another unscoped FLOOD, path len 3 -> repeaterB sees hop=2 this
// time, giving it hops [1, 2] (median matters for the assertion below).
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (3, 'h3', ?, 1, 5)`, recent)
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (3, 3, ?)`, `["`+repeaterA+`","`+origin+`","`+repeaterB+`"]`)
// tx4: unscoped FLOOD ADVERT -- must be EXCLUDED from unscopedByRepeater
// (adverts have their own separate flood.max.advert cap) but still
// counts toward the network-wide unscoped hop-depth bucket.
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (4, 'h4', ?, 1, 4)`, recent)
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (4, 4, ?)`, `["`+repeaterA+`"]`)
// tx5: DIRECT -- excluded from both scoped and unscoped flood buckets
// entirely (not a flood.max-relevant transport).
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (5, 'h5', ?, 2, 5)`, recent)
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (5, 5, ?)`, `["`+repeaterA+`"]`)
// tx6: companion (not repeater/room) relaying unscoped flood -- must
// NOT appear in unscopedByRepeater despite matching the transport filter.
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (6, 'h6', ?, 1, 5)`, recent)
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (6, 6, ?)`, `["`+origin+`"]`)
resp, err := db.GetHopDepthAnalytics("24h")
if err != nil {
t.Fatalf("GetHopDepthAnalytics: %v", err)
}
if resp.Window != "24h" {
t.Errorf("Window = %q, want 24h", resp.Window)
}
// Scoped bucket: only tx1 contributes -- one hop=0 entry.
scopedByHop := map[int]int{}
for _, b := range resp.ScopedHopDepth {
scopedByHop[b.Hops] = b.Count
}
if scopedByHop[0] != 1 || len(resp.ScopedHopDepth) != 1 {
t.Errorf("ScopedHopDepth = %+v, want just {hops:0 count:1}", resp.ScopedHopDepth)
}
// Unscoped bucket: tx2 (hop0,hop1) + tx3 (hop0,hop1,hop2) + tx4 (hop0)
// + tx6 (hop0) -> hop0: 4 (tx2,tx3,tx4,tx6), hop1: 2 (tx2,tx3), hop2: 1 (tx3).
unscopedByHop := map[int]int{}
for _, b := range resp.UnscopedHopDepth {
unscopedByHop[b.Hops] = b.Count
}
if unscopedByHop[0] != 4 {
t.Errorf("UnscopedHopDepth[hop=0] = %d, want 4", unscopedByHop[0])
}
if unscopedByHop[1] != 2 {
t.Errorf("UnscopedHopDepth[hop=1] = %d, want 2", unscopedByHop[1])
}
if unscopedByHop[2] != 1 {
t.Errorf("UnscopedHopDepth[hop=2] = %d, want 1", unscopedByHop[2])
}
// unscopedByRepeater: only repeaterA and repeaterB should appear
// (origin is a companion, tx4's advert excluded, tx1/tx5 not unscoped-flood).
byPK := map[string]RepeaterUnscopedHopDepth{}
for _, r := range resp.UnscopedByRepeater {
byPK[r.PublicKey] = r
}
if len(byPK) != 2 {
t.Fatalf("UnscopedByRepeater = %+v, want exactly repeaterA and repeaterB", resp.UnscopedByRepeater)
}
// repeaterA: hop=0 from tx2, hop=0 from tx3 -> [0,0], median 0.
a := byPK[repeaterA]
if a.Count != 2 || a.MinHops != 0 || a.MaxHops != 0 || a.MedianHops != 0 {
t.Errorf("repeaterA = %+v, want count=2 min=0 max=0 median=0", a)
}
// repeaterB: hop=1 from tx2, hop=2 from tx3 -> [1,2], median 1.5.
b := byPK[repeaterB]
if b.Count != 2 || b.MinHops != 1 || b.MaxHops != 2 || b.MedianHops != 1.5 {
t.Errorf("repeaterB = %+v, want count=2 min=1 max=2 median=1.5", b)
}
if _, ok := byPK[origin]; ok {
t.Error("origin (companion role) must not appear in UnscopedByRepeater")
}
}
func TestGetHopDepthAnalytics_EmptyWindow(t *testing.T) {
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
defer conn.Close()
conn.SetMaxOpenConns(1)
db := &DB{conn: conn}
conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`)
conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER)`)
conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, resolved_path TEXT)`)
resp, err := db.GetHopDepthAnalytics("1h")
if err != nil {
t.Fatalf("GetHopDepthAnalytics: %v", err)
}
if len(resp.ScopedHopDepth) != 0 || len(resp.UnscopedHopDepth) != 0 || len(resp.UnscopedByRepeater) != 0 || len(resp.TimeSeries) != 0 {
t.Errorf("expected all-empty response on an empty DB, got %+v", resp)
}
}
// TestGetHopDepthAnalytics_TimeSeries covers the per-time-bucket
// scoped/unscoped median hop trend: two transmissions two hours apart
// must land in two distinct 1-hour buckets (24h window), sorted
// chronologically, and a series with no samples in a given bucket must
// report nil (not 0 -- 0 is itself a valid median hop).
func TestGetHopDepthAnalytics_TimeSeries(t *testing.T) {
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
defer conn.Close()
conn.SetMaxOpenConns(1)
db := &DB{conn: conn}
conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`)
conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER)`)
conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, resolved_path TEXT)`)
repeaterA := "aa001111aaaabbbb"
conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'RepeaterA', 'repeater')`, repeaterA)
now := time.Now().UTC()
// Truncated-hour-aligned with a safe +10min offset so a slow test run
// can't accidentally straddle an hour boundary and merge the buckets.
oldBucket := now.Truncate(time.Hour).Add(-3*time.Hour + 10*time.Minute)
recentBucket := now.Truncate(time.Hour).Add(-1*time.Hour + 10*time.Minute)
// oldBucket: one scoped, single-hop path -> ScopedMedianHop=0, no
// unscoped traffic in this bucket at all -> UnscopedMedianHop=nil.
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (1, 'h1', ?, 0, 5)`, oldBucket.Format(time.RFC3339))
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (1, 1, ?)`, `["`+repeaterA+`"]`)
// recentBucket: one unscoped, single-hop path -> UnscopedMedianHop=0,
// no scoped traffic here -> ScopedMedianHop=nil.
conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (2, 'h2', ?, 1, 5)`, recentBucket.Format(time.RFC3339))
conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (2, 2, ?)`, `["`+repeaterA+`"]`)
resp, err := db.GetHopDepthAnalytics("24h")
if err != nil {
t.Fatalf("GetHopDepthAnalytics: %v", err)
}
if len(resp.TimeSeries) != 2 {
t.Fatalf("TimeSeries = %+v, want exactly 2 buckets", resp.TimeSeries)
}
old, recentPt := resp.TimeSeries[0], resp.TimeSeries[1]
if old.T >= recentPt.T {
t.Errorf("TimeSeries not chronologically sorted: %q should be before %q", old.T, recentPt.T)
}
if old.ScopedMedianHop == nil || *old.ScopedMedianHop != 0 {
t.Errorf("old bucket ScopedMedianHop = %v, want pointer to 0", old.ScopedMedianHop)
}
if old.UnscopedMedianHop != nil {
t.Errorf("old bucket UnscopedMedianHop = %v, want nil (no unscoped traffic that hour)", *old.UnscopedMedianHop)
}
if recentPt.UnscopedMedianHop == nil || *recentPt.UnscopedMedianHop != 0 {
t.Errorf("recent bucket UnscopedMedianHop = %v, want pointer to 0", recentPt.UnscopedMedianHop)
}
if recentPt.ScopedMedianHop != nil {
t.Errorf("recent bucket ScopedMedianHop = %v, want nil (no scoped traffic that hour)", *recentPt.ScopedMedianHop)
}
}
// TestHandleHopDepthAnalytics_InvalidWindow mirrors handleScopeStats'
// window validation.
func TestHandleHopDepthAnalytics_InvalidWindow(t *testing.T) {
db := setupTestDB(t)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/hop-depth?window=30d", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleHopDepthAnalytics_DefaultWindow(t *testing.T) {
db := setupTestDB(t)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/hop-depth", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp HopDepthAnalyticsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Window != "24h" {
t.Errorf("Window = %q, want default 24h", resp.Window)
}
}