Files
meshcore-analyzer/cmd/server/ping_scores_test.go
T
dborup 2f044a6a00 feat: add "This Week's Best" rolling-7-day records to Ping Scores
The all-time records lock in forever once someone sets a big one (e.g.
a 364km farthest ping) -- ThisWeek mirrors the same 5 slots on a
rolling 7-day window so there's an achievable target to chase
regularly, shown alongside (not instead of) the all-time board.
2026-07-28 13:56:47 +02:00

456 lines
19 KiB
Go

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")
}
}