package main import ( "encoding/json" "net/http/httptest" "testing" "time" "github.com/gorilla/mux" ) // seedPingTrigger inserts a CHAN transmission plus its ping_triggers row -- // what the ingestor would have written for a real "ping" message. func seedPingTrigger(t *testing.T, srv *Server, hash, channelHash, sender, firstSeen string) int64 { t.Helper() res, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) VALUES ('AABBCCDDEE', ?, ?, 1, 5, '{"type":"CHAN","channel":"'||?||'","text":"'||?||': ping","sender":"'||?||'"}', ?)`, hash, firstSeen, channelHash, sender, sender, channelHash) if err != nil { t.Fatalf("insert tx: %v", err) } txID, _ := res.LastInsertId() if _, err := srv.db.conn.Exec(`INSERT INTO ping_triggers (tx_id, hash, channel_hash, sender, first_seen) VALUES (?, ?, ?, ?, ?)`, txID, hash, channelHash, sender, firstSeen); err != nil { t.Fatalf("insert ping_trigger: %v", err) } return txID } func seedPingObservation(t *testing.T, srv *Server, txID int64, observerID string, snr float64, pathJSON, resolvedPath string, tsUnix int64) { t.Helper() var observerIdx int64 if err := srv.db.conn.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, observerID).Scan(&observerIdx); err != nil { t.Fatalf("lookup observer %s: %v", observerID, err) } if _, err := srv.db.conn.Exec( `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) VALUES (?,?,?,?,?,?,?)`, txID, observerIdx, snr, -90.0, pathJSON, resolvedPath, tsUnix, ); err != nil { t.Fatalf("insert observation: %v", err) } } // setupPingScoresFixture seeds observers with known positions (via nodes // row for lat/lon lookups the same way GetPacketPath resolves them) and // returns the server (+ router, for handler-level tests) ready for // computePingScore/computeAllPingScores. func setupPingScoresFixture(t *testing.T) (*Server, *mux.Router) { t.Helper() srv, router := setupTestServer(t) // setupTestDB's shared schema predates the ping-score feature; create // ping_triggers locally here rather than touching the schema shared by // every other test in the package. if _, err := srv.db.conn.Exec(`CREATE TABLE IF NOT EXISTS ping_triggers ( tx_id INTEGER PRIMARY KEY, hash TEXT NOT NULL, channel_hash TEXT, sender TEXT, first_seen TEXT NOT NULL )`); err != nil { t.Fatalf("create ping_triggers: %v", err) } obs := []struct { id, name, iata string }{ {"pingobsa", "ObsA", "AAA"}, {"pingobsb", "ObsB", "BBB"}, {"pingobsc", "ObsC", "CCC"}, } for _, o := range obs { if _, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, o.id, o.name, o.iata); err != nil { t.Fatalf("insert observer %s: %v", o.id, err) } } // Distinct positions so DistanceFromFirstKm is real and non-trivial. positions := []struct { pubkey string lat, lon float64 }{ {"pingobsa", 56.0, 10.0}, // first-hearer landmark for most scenarios {"pingobsb", 57.8, 12.6}, // ~230km from A {"pingobsc", 56.05, 10.05}, // ~6km from A } for _, p := range positions { if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, lat, lon) VALUES (?,?,?,?)`, p.pubkey, p.pubkey, p.lat, p.lon); err != nil { t.Fatalf("insert node position for %s: %v", p.pubkey, err) } } return srv, router } // TestComputePingScore_Basic covers the core per-ping derivation: deepest // (most hops) vs farthest (most distanceFromFirstKm) can be DIFFERENT // branches, spread seconds is the max across all branches, and the relay // pubkeys feeding the leaderboard are collected from every branch's hop // chain, not just the deepest/farthest one. func TestComputePingScore_Basic(t *testing.T) { srv, _ := setupPingScoresFixture(t) txID := seedPingTrigger(t, srv, "scorebasic00001", "#test", "Alice", "2026-01-15T10:00:00Z") // obs A: first hearer, 0 hops. seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, 1736935200) // obs B: farther away (230km) but fewer hops (1) than C. seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210) // obs C: closer (6km) but MORE hops (2) -- the deepest branch. seedPingObservation(t, srv, txID, "pingobsc", 4.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935260) score := srv.computePingScore(pingTriggerRow{txID: txID, hash: "scorebasic00001", channelHash: "#test", sender: "Alice", firstSeen: "2026-01-15T10:00:00Z"}) if score == nil { t.Fatal("computePingScore returned nil") } if score.StationCount != 3 { t.Errorf("StationCount = %d, want 3", score.StationCount) } if score.DeepestHops != 2 { t.Errorf("DeepestHops = %d, want 2 (obs C's branch)", score.DeepestHops) } if score.FarthestKm == nil { t.Fatal("FarthestKm is nil, want a value") } if *score.FarthestKm < 200 || *score.FarthestKm > 260 { t.Errorf("FarthestKm = %v, want ~230 (obs B's branch)", *score.FarthestKm) } if score.FarthestPubkey != "pingobsb" { t.Errorf("FarthestPubkey = %q, want pingobsb -- farthest and deepest must be independently attributed", score.FarthestPubkey) } if score.DeepestPubkey != "pingobsc" { t.Errorf("DeepestPubkey = %q, want pingobsc", score.DeepestPubkey) } if score.SpreadSeconds == nil || *score.SpreadSeconds < 59 || *score.SpreadSeconds > 61 { t.Errorf("SpreadSeconds = %v, want ~60 (obs C arrived 60s after obs A)", score.SpreadSeconds) } relaySet := map[string]bool{} for _, pk := range score.relayPubkeys { relaySet[pk] = true } if !relaySet["pkrelay1"] || !relaySet["pkrelay2"] { t.Errorf("relayPubkeys = %v, want both pkrelay1 and pkrelay2 collected across all branches", score.relayPubkeys) } if score.firstPubkey != "pingobsa" { t.Errorf("firstPubkey = %q, want pingobsa (earliest-arriving observation)", score.firstPubkey) } } // TestComputeAllPingScores_RecordSelection seeds several pings with // deliberately distinct winning stats and asserts each record picks the // right one -- including the fastest-full-spread record correctly // excluding a lone-station ping (StationCount<2) that would otherwise // trivially "win" with a 0-second spread. func TestComputeAllPingScores_RecordSelection(t *testing.T) { srv, _ := setupPingScoresFixture(t) // Ping FAR: farthest (230km) and deepest (2 hops). txFar := seedPingTrigger(t, srv, "recordfar000001", "#test", "Alice", "2026-01-15T10:00:00Z") seedPingObservation(t, srv, txFar, "pingobsa", 9.0, `[]`, `[]`, 1736935200) seedPingObservation(t, srv, txFar, "pingobsb", 6.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935210) // Ping WIDE: 3 stations (more than any other ping here) -- wins widest spread. txWide := seedPingTrigger(t, srv, "recordwide00001", "#test", "Bob", "2026-01-15T11:00:00Z") seedPingObservation(t, srv, txWide, "pingobsa", 9.0, `[]`, `[]`, 1736938800) seedPingObservation(t, srv, txWide, "pingobsb", 6.0, `["aa"]`, `["pkrelay3"]`, 1736938850) seedPingObservation(t, srv, txWide, "pingobsc", 4.0, `["aa"]`, `["pkrelay3"]`, 1736938900) // Ping FAST: 2 stations, arrives within 1 second -- wins fastest spread. txFast := seedPingTrigger(t, srv, "recordfast00001", "#test", "Carol", "2026-01-15T12:00:00Z") seedPingObservation(t, srv, txFast, "pingobsa", 9.0, `[]`, `[]`, 1736942400) seedPingObservation(t, srv, txFast, "pingobsc", 6.0, `["aa"]`, `["pkrelay4"]`, 1736942401) // Ping LONE: single station, 0-second "spread" -- must NOT win fastest // spread despite the smallest possible SpreadSeconds, since there's // nothing to spread to. txLone := seedPingTrigger(t, srv, "recordlone00001", "#test", "Dave", "2026-01-15T13:00:00Z") seedPingObservation(t, srv, txLone, "pingobsa", 9.0, `[]`, `[]`, 1736946000) snap := srv.computeAllPingScores() if snap == nil { t.Fatal("computeAllPingScores returned nil") } if snap.TotalPings != 4 { t.Errorf("TotalPings = %d, want 4", snap.TotalPings) } if snap.FarthestPing == nil || snap.FarthestPing.Hash != "recordfar000001" { t.Errorf("FarthestPing = %+v, want recordfar000001", snap.FarthestPing) } if snap.MostHopsPing == nil || snap.MostHopsPing.Hash != "recordfar000001" { t.Errorf("MostHopsPing = %+v, want recordfar000001", snap.MostHopsPing) } if snap.WidestSpreadPing == nil || snap.WidestSpreadPing.Hash != "recordwide00001" { t.Errorf("WidestSpreadPing = %+v, want recordwide00001 (3 stations)", snap.WidestSpreadPing) } if snap.FastestSpreadPing == nil || snap.FastestSpreadPing.Hash != "recordfast00001" { t.Errorf("FastestSpreadPing = %+v, want recordfast00001, NOT the lone-station ping", snap.FastestSpreadPing) } } // TestComputeAllPingScores_Leaderboards verifies relay credit is deduped // per-ping (a relay appearing in several branches of the SAME ping earns // only one point for it) while still tallying correctly ACROSS pings, and // that the observer leaderboard credits whoever was first each time. func TestComputeAllPingScores_Leaderboards(t *testing.T) { srv, _ := setupPingScoresFixture(t) // Ping 1: pkrelay1 appears in BOTH branches -- must count once for this ping. // Uses a recent (now-relative) timestamp since SenderLeaderboard is a // rolling 30-day window -- see TestComputeAllPingScores_SenderLeaderboard30DayCutoff. ts1 := time.Now().Add(-1 * time.Hour) tx1 := seedPingTrigger(t, srv, "lbtest0000001", "#test", "Alice", ts1.Format(time.RFC3339)) seedPingObservation(t, srv, tx1, "pingobsa", 9.0, `[]`, `[]`, ts1.Unix()) seedPingObservation(t, srv, tx1, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, ts1.Unix()+10) seedPingObservation(t, srv, tx1, "pingobsc", 4.0, `["aa"]`, `["pkrelay1"]`, ts1.Unix()+20) // Ping 2: pkrelay1 appears again -- second distinct ping, should now be 2. ts2 := time.Now().Add(-30 * time.Minute) tx2 := seedPingTrigger(t, srv, "lbtest0000002", "#test", "Bob", ts2.Format(time.RFC3339)) seedPingObservation(t, srv, tx2, "pingobsa", 9.0, `[]`, `[]`, ts2.Unix()) seedPingObservation(t, srv, tx2, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, ts2.Unix()+10) snap := srv.computeAllPingScores() if snap == nil { t.Fatal("computeAllPingScores returned nil") } var relay1Count int found := false for _, e := range snap.RelayLeaderboard { if e.Pubkey == "pkrelay1" { relay1Count = e.Count found = true } } if !found { t.Fatalf("pkrelay1 not found in RelayLeaderboard: %+v", snap.RelayLeaderboard) } if relay1Count != 2 { t.Errorf("pkrelay1 count = %d, want 2 (one per DISTINCT ping, not 3 for appearing in 3 branches total)", relay1Count) } var obsACount int for _, e := range snap.ObserverLeaderboard { if e.Pubkey == "pingobsa" { obsACount = e.Count } } if obsACount != 2 { t.Errorf("pingobsa first-hearer count = %d, want 2 (first on both pings)", obsACount) } // Alice sent ping 1, Bob sent ping 2 -- one each, and neither entry // should carry a pubkey since senders are keyed by display name only. senderCounts := map[string]PingLeaderboardEntry{} for _, e := range snap.SenderLeaderboard { senderCounts[e.Name] = e } if senderCounts["Alice"].Count != 1 { t.Errorf("Alice sender count = %+v, want Count=1", senderCounts["Alice"]) } if senderCounts["Bob"].Count != 1 { t.Errorf("Bob sender count = %+v, want Count=1", senderCounts["Bob"]) } if senderCounts["Alice"].Pubkey != "" { t.Errorf("Alice entry has Pubkey=%q, want empty -- senders have no resolved pubkey", senderCounts["Alice"].Pubkey) } } // TestComputeAllPingScores_SenderLeaderboard30DayCutoff confirms // SenderLeaderboard only counts pings from the last 30 days, while the // other leaderboards and the 5 all-time records stay unaffected by a ping's // age. func TestComputeAllPingScores_SenderLeaderboard30DayCutoff(t *testing.T) { srv, _ := setupPingScoresFixture(t) // Old: 40 days ago -- outside the 30-day window, must NOT appear in // SenderLeaderboard, but must still count in RelayLeaderboard. oldTs := time.Now().AddDate(0, 0, -40) txOld := seedPingTrigger(t, srv, "lbold0000001", "#test", "OldSender", oldTs.Format(time.RFC3339)) seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix()) seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10) // Recent: 5 days ago -- inside the window, must appear in SenderLeaderboard. recentTs := time.Now().AddDate(0, 0, -5) txRecent := seedPingTrigger(t, srv, "lbnew0000001", "#test", "RecentSender", recentTs.Format(time.RFC3339)) seedPingObservation(t, srv, txRecent, "pingobsa", 9.0, `[]`, `[]`, recentTs.Unix()) seedPingObservation(t, srv, txRecent, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, recentTs.Unix()+10) snap := srv.computeAllPingScores() if snap == nil { t.Fatal("computeAllPingScores returned nil") } for _, e := range snap.SenderLeaderboard { if e.Name == "OldSender" { t.Errorf("OldSender (40 days old) must be excluded from SenderLeaderboard, got entry: %+v", e) } } var foundRecent bool for _, e := range snap.SenderLeaderboard { if e.Name == "RecentSender" { foundRecent = true if e.Count != 1 { t.Errorf("RecentSender count = %d, want 1", e.Count) } } } if !foundRecent { t.Errorf("RecentSender (5 days old) missing from SenderLeaderboard: %+v", snap.SenderLeaderboard) } // The 40-day-old ping's relay must still count -- only the sender // leaderboard is time-windowed, not the underlying data or other boards. var relay1Count int for _, e := range snap.RelayLeaderboard { if e.Pubkey == "pkrelay1" { relay1Count = e.Count } } if relay1Count != 2 { t.Errorf("pkrelay1 relay count = %d, want 2 (both old and recent pings count toward RelayLeaderboard)", relay1Count) } } // TestComputeAllPingScores_ThisWeek confirms ThisWeek is an independently // windowed record set: an older, BIGGER ping still wins the all-time slot, // while a smaller but recent ping wins the equivalent ThisWeek slot since // the older one falls outside the 7-day window. func TestComputeAllPingScores_ThisWeek(t *testing.T) { srv, _ := setupPingScoresFixture(t) // Old: 10 days ago, farther (pingobsb, ~230km from pingobsa) -- must // win the all-time FarthestPing but be excluded from ThisWeek. oldTs := time.Now().AddDate(0, 0, -10) txOld := seedPingTrigger(t, srv, "weekold0000001", "#test", "Alice", oldTs.Format(time.RFC3339)) seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix()) seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10) // Recent: 2 days ago, closer (pingobsc, ~6km from pingobsa) -- the // only ping inside the 7-day window, so it must win ThisWeek's // FarthestPing even though it's smaller than the old one. recentTs := time.Now().AddDate(0, 0, -2) txRecent := seedPingTrigger(t, srv, "weeknew0000001", "#test", "Bob", recentTs.Format(time.RFC3339)) seedPingObservation(t, srv, txRecent, "pingobsa", 9.0, `[]`, `[]`, recentTs.Unix()) seedPingObservation(t, srv, txRecent, "pingobsc", 6.0, `["aa"]`, `["pkrelay2"]`, recentTs.Unix()+10) snap := srv.computeAllPingScores() if snap == nil { t.Fatal("computeAllPingScores returned nil") } if snap.FarthestPing == nil || snap.FarthestPing.Hash != "weekold0000001" { t.Errorf("all-time FarthestPing = %+v, want weekold0000001 (the farther, older ping)", snap.FarthestPing) } if snap.ThisWeek == nil { t.Fatal("ThisWeek is nil, want a populated record set from the recent ping") } if snap.ThisWeek.FarthestPing == nil || snap.ThisWeek.FarthestPing.Hash != "weeknew0000001" { t.Errorf("ThisWeek.FarthestPing = %+v, want weeknew0000001 -- the old ping is outside the 7-day window", snap.ThisWeek.FarthestPing) } } // TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings confirms ThisWeek // stays nil (not a zero-valued struct) when every ping is outside the // 7-day window, matching the frontend's null-safe "no record yet" // rendering rather than an empty-but-present object. func TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings(t *testing.T) { srv, _ := setupPingScoresFixture(t) oldTs := time.Now().AddDate(0, 0, -30) txOld := seedPingTrigger(t, srv, "weeknone000001", "#test", "Alice", oldTs.Format(time.RFC3339)) seedPingObservation(t, srv, txOld, "pingobsa", 9.0, `[]`, `[]`, oldTs.Unix()) seedPingObservation(t, srv, txOld, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, oldTs.Unix()+10) snap := srv.computeAllPingScores() if snap == nil { t.Fatal("computeAllPingScores returned nil") } if snap.ThisWeek != nil { t.Errorf("ThisWeek = %+v, want nil when no ping in the last 7 days resolved to a usable score", snap.ThisWeek) } } // TestHandlePingScores_EmptyState confirms the endpoint returns a // well-formed 200 with zero-valued/omitted fields rather than an error // when no ping has ever been recorded -- an ordinary state, not a failure. func TestHandlePingScores_EmptyState(t *testing.T) { srv, router := setupTestServer(t) srv.pingScores.Store(&PingScoresSnapshot{GeneratedAt: time.Now().UTC().Format(time.RFC3339)}) req := httptest.NewRequest("GET", "/api/ping-scores", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != 200 { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } var resp PingScoresSnapshot if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.TotalPings != 0 { t.Errorf("TotalPings = %d, want 0", resp.TotalPings) } if resp.FarthestPing != nil { t.Errorf("FarthestPing = %+v, want nil/omitted with no ping history", resp.FarthestPing) } } // TestHandlePingScores_NilCache confirms the handler tolerates the cache // never having been populated at all (e.g. a request racing the // recomputer's first tick) rather than panicking on a nil pointer. func TestHandlePingScores_NilCache(t *testing.T) { _, router := setupTestServer(t) req := httptest.NewRequest("GET", "/api/ping-scores", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != 200 { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } var resp PingScoresSnapshot if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.GeneratedAt == "" { t.Error("GeneratedAt should still be populated even when the cache was never warmed") } } // TestHandlePingScores_Populated exercises the full HTTP path end-to-end // against real fixture data, confirming the JSON shape matches openapi.go // (nested PingScore objects, leaderboards present). func TestHandlePingScores_Populated(t *testing.T) { srv, router := setupPingScoresFixture(t) ts := time.Now().Add(-1 * time.Hour) txID := seedPingTrigger(t, srv, "handlerpop00001", "#test", "Alice", ts.Format(time.RFC3339)) seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, ts.Unix()) seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, ts.Unix()+10) srv.pingScores.Store(srv.computeAllPingScores()) req := httptest.NewRequest("GET", "/api/ping-scores", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != 200 { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } var raw map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("decode raw: %v", err) } if _, present := raw["farthestPing"]; !present { t.Error("expected farthestPing in the JSON response") } if _, present := raw["relayLeaderboard"]; !present { t.Error("expected relayLeaderboard in the JSON response") } if _, present := raw["senderLeaderboard"]; !present { t.Error("expected senderLeaderboard in the JSON response") } }