mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 05:27:55 +00:00
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>
265 lines
11 KiB
Go
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)
|
|
}
|
|
}
|