Files
meshcore-analyzer/cmd/server/db_test.go
T
dborup 27628fbc3a feat: capture and chart SNR history per direct neighbor (#1865 follow-up)
dborup spotted the raw /neighbors payload also carries snr and
heard_secs_ago per neighbor -- previously dropped entirely by
handleNeighborsReport's parsing loop. Both fields are present regardless
of scope-query status (responded or timeout).

New append-only observer_neighbor_metrics table, inspired by the existing
RF Health tab's observer_metrics pattern: one row per neighbor per
/neighbors report, pruned on the same 30-day MetricsRetentionDays
schedule. Deliberately no ordering guard on the write side (unlike the
current-only observer_neighbors snapshot) -- every report is valid
history at its own timestamp.

GET /api/observers/{id}/neighbors/{pubkey}/metrics serves the raw
(undownsampled) history. Frontend renders a per-row SNR sparkline on the
Direct Neighbors panel using the same lightweight inline-SVG technique as
the RF Health grid's noise-floor sparklines (no Chart.js), loaded async
after the panel paints.
2026-07-26 19:21:54 +02:00

3444 lines
128 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
)
// setupTestDB creates an in-memory SQLite database with the v3 schema.
func setupTestDB(t *testing.T) *DB {
t.Helper()
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
// Force single connection so all goroutines share the same in-memory DB
conn.SetMaxOpenConns(1)
// Create schema matching MeshCore Analyzer v3
schema := `
CREATE TABLE nodes (
public_key TEXT PRIMARY KEY,
name TEXT,
role TEXT,
lat REAL,
lon REAL,
last_seen TEXT,
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
);
CREATE TABLE observers (
id TEXT PRIMARY KEY,
name TEXT,
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0,
model TEXT,
firmware TEXT,
client_version TEXT,
radio TEXT,
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor REAL,
inactive INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL,
last_neighbors_report_at TEXT DEFAULT NULL,
clock_skew_seconds INTEGER DEFAULT NULL,
clock_skew_count_24h INTEGER DEFAULT 0,
clock_last_naive_at TEXT DEFAULT NULL
);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
channel_hash TEXT DEFAULT NULL,
from_pubkey TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL,
resolved_path TEXT,
raw_hex TEXT
);
CREATE TABLE IF NOT EXISTS observer_metrics (
observer_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
noise_floor REAL,
tx_air_secs INTEGER,
rx_air_secs INTEGER,
recv_errors INTEGER,
battery_mv INTEGER,
packets_sent INTEGER,
packets_recv INTEGER,
PRIMARY KEY (observer_id, timestamp)
);
CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp);
CREATE TABLE observer_neighbors (
observer_id TEXT NOT NULL,
neighbor_pubkey TEXT NOT NULL,
scopes TEXT,
status TEXT NOT NULL,
reported_at TEXT,
PRIMARY KEY (observer_id, neighbor_pubkey)
);
CREATE TABLE neighbor_edges (
node_a TEXT NOT NULL,
node_b TEXT NOT NULL,
count INTEGER DEFAULT 1,
last_seen TEXT,
PRIMARY KEY (node_a, node_b)
);
CREATE TABLE observer_neighbor_metrics (
observer_id TEXT NOT NULL,
neighbor_pubkey TEXT NOT NULL,
timestamp TEXT NOT NULL,
snr REAL,
heard_secs_ago INTEGER,
PRIMARY KEY (observer_id, neighbor_pubkey, timestamp)
);
-- Auto-populate from_pubkey for ADVERT rows so existing test fixtures
-- (which only set decoded_json) still attribute correctly under #1143's
-- exact-match column. Production migration handles legacy data; the
-- ingestor sets the column at write time.
--
-- m4 alignment: prod ingest leaves from_pubkey NULL when pubKey is
-- missing or empty (cmd/ingestor/db.go ~1289 guards PubKey != empty-string).
-- The trigger mirrors that: only assign when json_extract yields a
-- non-empty string. json_extract returns NULL for missing keys, so
-- the explicit IS NOT NULL AND <> empty-string guard catches the empty-string
-- case too. UPDATE only when we have something to write.
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
-- Mirror prod indexes from internal/dbschema/dbschema.go so query plans
-- in tests match prod. idx_observations_transmission_id is required by
-- GetChannelMessages's grouped MAX(timestamp) per tx aggregate
-- (issue #1366 / PR #1368): without it the perf test on 1500 tx × 50 obs
-- blows the 1.5s budget under -race.
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
CREATE INDEX IF NOT EXISTS idx_observations_tx_ts ON observations(transmission_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_transmissions_channel_hash ON transmissions(channel_hash);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
}
return &DB{conn: conn, isV3: true, hasResolvedPath: true}
}
func seedTestData(t *testing.T, db *DB) {
t.Helper()
// Use recent timestamps so 7-day window filters don't exclude test data
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
yesterday := now.Add(-24 * time.Hour).Format(time.RFC3339)
twoDaysAgo := now.Add(-48 * time.Hour).Format(time.RFC3339)
recentEpoch := now.Add(-1 * time.Hour).Unix()
yesterdayEpoch := now.Add(-24 * time.Hour).Unix()
// Seed observers
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs1', 'Observer One', 'SJC', ?, '2026-01-01T00:00:00Z', 100)`, recent)
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs2', 'Observer Two', 'SFO', ?, '2026-01-01T00:00:00Z', 50)`, yesterday)
// Seed nodes
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('aabbccdd11223344', 'TestRepeater', 'repeater', 37.5, -122.0, ?, '2026-01-01T00:00:00Z', 50)`, recent)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('eeff00112233aabb', 'TestCompanion', 'companion', 37.6, -122.1, ?, '2026-01-01T00:00:00Z', 10)`, yesterday)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('1122334455667788', 'TestRoom', 'room', 37.4, -121.9, ?, '2026-01-01T00:00:00Z', 5)`, twoDaysAgo)
// Seed transmissions
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash, from_pubkey)
VALUES ('AABB', 'abc123def4567890', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000000,"timestampISO":"2023-11-14T22:13:20.000Z","signature":"abcdef","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', '#test', 'aabbccdd11223344')`, recent)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CCDD', '1234567890abcdef', ?, 1, 5, '{"type":"CHAN","channel":"#test","text":"Hello: World","sender":"TestUser"}', '#test')`, yesterday)
// Second ADVERT for same node with different hash_size (raw_hex byte 0x1F → hs=1 vs 0xBB → hs=3)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', 'aabbccdd11223344')`, yesterday)
// Seed observations (use unix timestamps)
// resolved_path contains full pubkeys parallel to path_json hops
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?, '["aabbccdd11223344","eeff00112233aabb"]')`, recentEpoch)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (1, 2, 8.0, -95, '["aa"]', ?, '["aabbccdd11223344"]')`, recentEpoch-100)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 1, 15.0, -85, '[]', ?)`, yesterdayEpoch)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (3, 1, 10.0, -92, '["cc"]', ?, '["1122334455667788"]')`, yesterdayEpoch)
}
func TestGetStats(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
stats, err := db.GetStats()
if err != nil {
t.Fatal(err)
}
if stats.TotalTransmissions != 3 {
t.Errorf("expected 3 transmissions, got %d", stats.TotalTransmissions)
}
if stats.TotalNodes != 3 {
t.Errorf("expected 3 nodes, got %d", stats.TotalNodes)
}
if stats.TotalObservers != 2 {
t.Errorf("expected 2 observers, got %d", stats.TotalObservers)
}
if stats.TotalObservations != 4 {
t.Errorf("expected 4 observations, got %d", stats.TotalObservations)
}
}
func TestGetRoleCounts(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
counts := db.GetRoleCounts()
if counts["repeaters"] != 1 {
t.Errorf("expected 1 repeater, got %d", counts["repeaters"])
}
if counts["companions"] != 1 {
t.Errorf("expected 1 companion, got %d", counts["companions"])
}
if counts["rooms"] != 1 {
t.Errorf("expected 1 room, got %d", counts["rooms"])
}
}
func TestGetDBSizeStats(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
stats := db.GetDBSizeStats()
// In-memory DB has dbSizeMB=0 and walSizeMB=0
if stats["dbSizeMB"] != float64(0) {
t.Errorf("expected dbSizeMB=0 for in-memory DB, got %v", stats["dbSizeMB"])
}
rows, ok := stats["rows"].(map[string]int)
if !ok {
t.Fatal("expected rows map in DB size stats")
}
if rows["transmissions"] != 3 {
t.Errorf("expected 3 transmissions rows, got %d", rows["transmissions"])
}
if rows["observations"] != 4 {
t.Errorf("expected 4 observations rows, got %d", rows["observations"])
}
if rows["nodes"] != 3 {
t.Errorf("expected 3 nodes rows, got %d", rows["nodes"])
}
if rows["observers"] != 2 {
t.Errorf("expected 2 observers rows, got %d", rows["observers"])
}
// Verify new PRAGMA-based fields
if _, ok := stats["freelistMB"]; !ok {
t.Error("expected freelistMB in DB size stats")
}
walPages, ok := stats["walPages"].(map[string]interface{})
if !ok {
t.Fatal("expected walPages object in DB size stats")
}
for _, key := range []string{"total", "checkpointed", "busy"} {
if _, ok := walPages[key]; !ok {
t.Errorf("expected %s in walPages", key)
}
}
}
func TestQueryPackets(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
result, err := db.QueryPackets(PacketQuery{Limit: 50, Order: "DESC"})
if err != nil {
t.Fatal(err)
}
// Transmission-centric: 3 unique transmissions (not 4 observations)
if result.Total != 3 {
t.Errorf("expected 3 total transmissions, got %d", result.Total)
}
if len(result.Packets) != 3 {
t.Errorf("expected 3 packets, got %d", len(result.Packets))
}
// Verify transmission shape has required fields
if len(result.Packets) > 0 {
p := result.Packets[0]
if _, ok := p["first_seen"]; !ok {
t.Error("expected first_seen field in packet")
}
if _, ok := p["observation_count"]; !ok {
t.Error("expected observation_count field in packet")
}
if _, ok := p["timestamp"]; !ok {
t.Error("expected timestamp field in packet")
}
// Should NOT have observation-level fields at top
if _, ok := p["created_at"]; ok {
t.Error("did not expect created_at in transmission-level response")
}
if _, ok := p["score"]; ok {
t.Error("did not expect score in transmission-level response")
}
}
}
func TestQueryPacketsWithTypeFilter(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
pt := 4
result, err := db.QueryPackets(PacketQuery{Limit: 50, Type: &pt, Order: "DESC"})
if err != nil {
t.Fatal(err)
}
// 2 transmissions with payload_type=4 (ADVERT)
if result.Total != 2 {
t.Errorf("expected 2 ADVERT transmissions, got %d", result.Total)
}
}
func TestQueryGroupedPackets(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50})
if err != nil {
t.Fatal(err)
}
if result.Total != 3 {
t.Errorf("expected 3 grouped packets (unique hashes), got %d", result.Total)
}
}
func TestGetNodeByPubkey(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
node, err := db.GetNodeByPubkey("aabbccdd11223344")
if err != nil {
t.Fatal(err)
}
if node == nil {
t.Fatal("expected node, got nil")
}
if node["name"] != "TestRepeater" {
t.Errorf("expected TestRepeater, got %v", node["name"])
}
}
func TestGetNodeByPubkeyNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
node, _ := db.GetNodeByPubkey("nonexistent")
if node != nil {
t.Error("expected nil for nonexistent node")
}
}
func TestSearchNodes(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
nodes, err := db.SearchNodes("Test", 10)
if err != nil {
t.Fatal(err)
}
if len(nodes) != 3 {
t.Errorf("expected 3 nodes matching 'Test', got %d", len(nodes))
}
}
func TestGetObservers(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
if len(observers) != 2 {
t.Errorf("expected 2 observers, got %d", len(observers))
}
if observers[0].ID != "obs1" {
t.Errorf("expected obs1 first (most recent), got %s", observers[0].ID)
}
// last_packet_at should be nil since seedTestData doesn't set it
if observers[0].LastPacketAt != nil {
t.Errorf("expected nil LastPacketAt for obs1 from seed, got %v", *observers[0].LastPacketAt)
}
}
// Regression: GetObservers must exclude soft-deleted (inactive=1) rows.
// Stale observers were appearing in /api/observers despite the auto-prune
// marking them inactive, because the SELECT query had no WHERE filter.
func TestGetObservers_ExcludesInactive(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Mark obs2 inactive — soft delete simulating a stale-observer prune.
if _, err := db.conn.Exec(`UPDATE observers SET inactive = 1 WHERE id = ?`, "obs2"); err != nil {
t.Fatalf("update inactive: %v", err)
}
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
if len(observers) != 1 {
t.Errorf("expected 1 observer (obs1) after marking obs2 inactive, got %d", len(observers))
}
for _, o := range observers {
if o.ID == "obs2" {
t.Errorf("inactive observer obs2 should be excluded")
}
}
}
func TestGetObserverByID(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
obs, err := db.GetObserverByID("obs1")
if err != nil {
t.Fatal(err)
}
if obs.ID != "obs1" {
t.Errorf("expected obs1, got %s", obs.ID)
}
// Verify last_packet_at is nil by default
if obs.LastPacketAt != nil {
t.Errorf("expected nil LastPacketAt, got %v", *obs.LastPacketAt)
}
}
func TestGetObserverLastPacketAt(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Set last_packet_at for obs1
ts := "2026-04-24T12:00:00Z"
db.conn.Exec(`UPDATE observers SET last_packet_at = ? WHERE id = ?`, ts, "obs1")
// Verify via GetObservers
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
var obs1 *Observer
for i := range observers {
if observers[i].ID == "obs1" {
obs1 = &observers[i]
break
}
}
if obs1 == nil {
t.Fatal("obs1 not found")
}
if obs1.LastPacketAt == nil || *obs1.LastPacketAt != ts {
t.Errorf("expected LastPacketAt=%s via GetObservers, got %v", ts, obs1.LastPacketAt)
}
// Verify via GetObserverByID
obs, err := db.GetObserverByID("obs1")
if err != nil {
t.Fatal(err)
}
if obs.LastPacketAt == nil || *obs.LastPacketAt != ts {
t.Errorf("expected LastPacketAt=%s via GetObserverByID, got %v", ts, obs.LastPacketAt)
}
}
func TestGetObserverByIDNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
_, err := db.GetObserverByID("nonexistent")
if err == nil {
t.Error("expected error for nonexistent observer")
}
}
func TestObserverTypeConsistency(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert observer with typed metadata matching ingestor writes
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, battery_mv, uptime_secs, noise_floor)
VALUES ('obs_typed', 'TypedObs', 'SJC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 10, 3500, 86400, -115.5)`)
obs, err := db.GetObserverByID("obs_typed")
if err != nil {
t.Fatal(err)
}
// battery_mv should be *int
if obs.BatteryMv == nil {
t.Fatal("BatteryMv should not be nil")
}
if *obs.BatteryMv != 3500 {
t.Errorf("BatteryMv=%d, want 3500", *obs.BatteryMv)
}
// uptime_secs should be *int64
if obs.UptimeSecs == nil {
t.Fatal("UptimeSecs should not be nil")
}
if *obs.UptimeSecs != 86400 {
t.Errorf("UptimeSecs=%d, want 86400", *obs.UptimeSecs)
}
// noise_floor should be *float64
if obs.NoiseFloor == nil {
t.Fatal("NoiseFloor should not be nil")
}
if *obs.NoiseFloor != -115.5 {
t.Errorf("NoiseFloor=%f, want -115.5", *obs.NoiseFloor)
}
// Verify NULL handling: observer without metadata
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs_null', 'NullObs', 'SFO', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 5)`)
obsNull, err := db.GetObserverByID("obs_null")
if err != nil {
t.Fatal(err)
}
if obsNull.BatteryMv != nil {
t.Errorf("BatteryMv should be nil for observer without metadata, got %d", *obsNull.BatteryMv)
}
if obsNull.UptimeSecs != nil {
t.Errorf("UptimeSecs should be nil for observer without metadata, got %d", *obsNull.UptimeSecs)
}
if obsNull.NoiseFloor != nil {
t.Errorf("NoiseFloor should be nil for observer without metadata, got %f", *obsNull.NoiseFloor)
}
}
func TestObserverTypesInGetObservers(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, battery_mv, uptime_secs, noise_floor)
VALUES ('obs1', 'Obs1', 'SJC', '2026-06-01T00:00:00Z', '2026-01-01T00:00:00Z', 10, 4200, 172800, -110.3)`)
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
if len(observers) != 1 {
t.Fatalf("expected 1 observer, got %d", len(observers))
}
o := observers[0]
if o.BatteryMv == nil || *o.BatteryMv != 4200 {
t.Errorf("BatteryMv=%v, want 4200", o.BatteryMv)
}
if o.UptimeSecs == nil || *o.UptimeSecs != 172800 {
t.Errorf("UptimeSecs=%v, want 172800", o.UptimeSecs)
}
if o.NoiseFloor == nil || *o.NoiseFloor != -110.3 {
t.Errorf("NoiseFloor=%v, want -110.3", o.NoiseFloor)
}
}
func TestGetDistinctIATAs(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
codes, err := db.GetDistinctIATAs()
if err != nil {
t.Fatal(err)
}
if len(codes) != 2 {
t.Errorf("expected 2 IATA codes, got %d", len(codes))
}
}
func TestGetPacketByHash(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
pkt, err := db.GetPacketByHash("abc123def4567890")
if err != nil {
t.Fatal(err)
}
if pkt == nil {
t.Fatal("expected packet, got nil")
}
if pkt["hash"] != "abc123def4567890" {
t.Errorf("expected hash abc123def4567890, got %v", pkt["hash"])
}
}
func TestGetTraces(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
traces, err := db.GetTraces("abc123def4567890")
if err != nil {
t.Fatal(err)
}
if len(traces) != 2 {
t.Errorf("expected 2 traces, got %d", len(traces))
}
}
// TestGetPacketPath covers the "View path" map data source: given a
// packet hash, resolve every distinct station's OWN deepest observation
// (not just the single farthest one overall) to a branch of
// name/role/lat/lon per hop, plus that station's IATA-derived position.
// Deliberately independent of seedTestData's fixtures.
func TestGetPacketPath(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkAlpha', 'RepeaterAlpha', 'repeater', 56.1, 10.2)`)
// pkBravo deliberately has NO nodes row -- exercises the raw-pubkey/no-position fallback.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000001', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
// Shallow observation (obs1): 1 hop.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlpha"]', 1736935200)`)
// Deeper observation (obs2): 2 hops.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 2, 4.0, -95, '["aa","bb"]', '["pkAlpha","pkBravo"]', 1736935260)`)
resp, err := db.GetPacketPath("pathtest00000001")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 2 {
t.Fatalf("Branches = %+v, want 2 (one per distinct observer)", resp.Branches)
}
// Sorted deepest-first: obs2's 2-hop branch, then obs1's 1-hop branch.
deep, shallow := resp.Branches[0], resp.Branches[1]
if deep.Hops != 2 {
t.Fatalf("Branches[0].Hops = %d, want 2 (the deeper branch first)", deep.Hops)
}
if len(deep.Points) != 2 {
t.Fatalf("Branches[0].Points = %+v, want 2 entries", deep.Points)
}
if deep.Points[0].Name != "RepeaterAlpha" || deep.Points[0].Lat == nil || *deep.Points[0].Lat != 56.1 {
t.Errorf("Branches[0].Points[0] = %+v, want RepeaterAlpha at lat 56.1", deep.Points[0])
}
if deep.Points[1].PublicKey != "pkBravo" || deep.Points[1].Name != "pkBravo" || deep.Points[1].Lat != nil {
t.Errorf("Branches[0].Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", deep.Points[1])
}
if deep.Observer == nil || deep.Observer.Name != "Observer Two" {
t.Fatalf("Branches[0].Observer = %+v, want Observer Two", deep.Observer)
}
if deep.Observer.Lat == nil || *deep.Observer.Lat != 37.6213 {
t.Errorf("Branches[0].Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", deep.Observer.Lat)
}
if deep.Observer.PublicKey != "obs2" {
t.Errorf("Branches[0].Observer.PublicKey = %q, want obs2 (its observers.id)", deep.Observer.PublicKey)
}
if shallow.Hops != 1 || shallow.Observer == nil || shallow.Observer.Name != "Observer One" {
t.Fatalf("Branches[1] = %+v, want Observer One's 1-hop branch", shallow)
}
}
// TestGetPacketPath_First covers the First field: the single
// earliest-arriving observation across every station, independent of
// which observer it came from or how many hops it took -- an approximate
// "where the message entered the mesh" landmark, distinct from Branches[0]
// (the deepest, i.e. farthest-traveled, branch).
func TestGetPacketPath_First(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsEarly', 'Observer Early', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsDeep', 'Observer Deep', 'SFO')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsMid', 'Observer Mid', 'OAK')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000007', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
// Earliest in time (timestamp=100), but shallow (0 hops, direct).
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '[]', 100)`)
// Arrives later, but travels deepest (5 hops) -- this is Branches[0].
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 4.0, -95, '["aa","bb","cc","dd","ee"]', 200)`)
// Arrives last, middling depth.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 3, 6.0, -90, '["aa","bb"]', 300)`)
resp, err := db.GetPacketPath("pathtest00000007")
if err != nil {
t.Fatal(err)
}
if resp.First == nil {
t.Fatalf("First = nil, want the earliest observation")
}
if resp.First.Observer == nil || resp.First.Observer.Name != "Observer Early" {
t.Errorf("First.Observer = %+v, want Observer Early (timestamp=100, the earliest)", resp.First.Observer)
}
if resp.First.Hops != 0 {
t.Errorf("First.Hops = %d, want 0 (Observer Early's own observation was direct)", resp.First.Hops)
}
if len(resp.Branches) == 0 || resp.Branches[0].Observer == nil || resp.Branches[0].Observer.Name != "Observer Deep" {
t.Fatalf("Branches[0] = %+v, want Observer Deep still first (deepest-first ordering unaffected by First)", resp.Branches)
}
if resp.First.SecondsAfterFirst == nil || *resp.First.SecondsAfterFirst != 0 {
t.Errorf("First.SecondsAfterFirst = %v, want 0 -- it defines the reference point", resp.First.SecondsAfterFirst)
}
// Observer Deep arrived at timestamp=200, Observer Early (First) at
// timestamp=100 -- 100 seconds later.
deep := resp.Branches[0]
if deep.SecondsAfterFirst == nil || *deep.SecondsAfterFirst != 100 {
t.Errorf("Branches[0].SecondsAfterFirst = %v, want 100 (arrived at ts=200, 100s after First's ts=100)", deep.SecondsAfterFirst)
}
// Observer Mid arrived at timestamp=300 -- 200 seconds after First.
var mid *PacketPathBranch
for i := range resp.Branches {
if resp.Branches[i].Observer != nil && resp.Branches[i].Observer.Name == "Observer Mid" {
mid = &resp.Branches[i]
}
}
if mid == nil {
t.Fatalf("Branches = %+v, want an Observer Mid branch", resp.Branches)
}
if mid.SecondsAfterFirst == nil || *mid.SecondsAfterFirst != 200 {
t.Errorf("Observer Mid.SecondsAfterFirst = %v, want 200 (arrived at ts=300, 200s after First's ts=100)", mid.SecondsAfterFirst)
}
// SJC/SFO/OAK (Observer Early/Deep/Mid's IATA positions) are all
// real, non-approx Bay Area airport coordinates -- distances should
// be computed, First's own distance is exactly 0, and the others are
// a real (bounded, Bay-Area-scale) positive distance.
if resp.First.DistanceFromFirstKm == nil || *resp.First.DistanceFromFirstKm != 0 {
t.Errorf("First.DistanceFromFirstKm = %v, want 0 -- it defines the reference point", resp.First.DistanceFromFirstKm)
}
if deep.DistanceFromFirstKm == nil || *deep.DistanceFromFirstKm <= 0 || *deep.DistanceFromFirstKm > 200 {
t.Errorf("Branches[0].DistanceFromFirstKm = %v, want a positive, Bay-Area-scale distance from SJC to SFO", deep.DistanceFromFirstKm)
}
if mid.DistanceFromFirstKm == nil || *mid.DistanceFromFirstKm <= 0 || *mid.DistanceFromFirstKm > 200 {
t.Errorf("Observer Mid.DistanceFromFirstKm = %v, want a positive, Bay-Area-scale distance from SJC to OAK", mid.DistanceFromFirstKm)
}
}
// TestGetPacketPath_DistanceOmittedWhenApprox covers the "don't compound
// an estimate on top of another estimate" rule: a branch whose Observer
// position is itself Approx (borrowed from a neighbor, see
// nearestPositionedNeighbor) must not get a DistanceFromFirstKm, even
// though First has a real position -- the result would be a distance to
// a guess, not a real measurement.
func TestGetPacketPath_DistanceOmittedWhenApprox(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (node_a TEXT NOT NULL, node_b TEXT NOT NULL, count INTEGER DEFAULT 1, last_seen TEXT, PRIMARY KEY (node_a, node_b))`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsfirst', 'Observer First', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsghost', 'Ghost Observer', NULL)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('obsghost', 'Ghost Observer', 'repeater')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkanchor', 'AnchorRepeater', 'repeater', 55.5, 9.5)`)
db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count) VALUES ('obsghost', 'pkanchor', 10)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000011', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
// obsfirst: earliest (ts=100), real IATA position -- this is First.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '[]', 100)`)
// obsghost: later (ts=200), deeper (2 hops) -- Branches[0], but its
// only position comes from the neighbor-centroid fallback (Approx).
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 4.0, -95, '["aa","bb"]', 200)`)
resp, err := db.GetPacketPath("pathtest00000011")
if err != nil {
t.Fatal(err)
}
if resp.First == nil || resp.First.Observer == nil || resp.First.Observer.Name != "Observer First" {
t.Fatalf("First = %+v, want Observer First", resp.First)
}
if resp.First.Observer.Lat == nil {
t.Fatalf("First.Observer.Lat = nil, want a real IATA-derived position")
}
// Two distinct observers each contribute their own branch (obsfirst's
// own 0-hop observation is also a branch in its own right, separate
// from it being First) -- find Ghost Observer's specifically.
var ghost *PacketPathBranch
for i := range resp.Branches {
if resp.Branches[i].Observer != nil && resp.Branches[i].Observer.Name == "Ghost Observer" {
ghost = &resp.Branches[i]
}
}
if ghost == nil {
t.Fatalf("Branches = %+v, want a Ghost Observer branch", resp.Branches)
}
if !ghost.Observer.Approx {
t.Fatalf("Ghost Observer.Approx = false, want true (positioned only via the neighbor fallback)")
}
if ghost.DistanceFromFirstKm != nil {
t.Errorf("Ghost Observer.DistanceFromFirstKm = %v, want nil -- its own position is itself an estimate", ghost.DistanceFromFirstKm)
}
}
// TestGetPacketPath_ExcludesNullIsland covers a node whose nodes.lat/lon
// are stored as literal (0,0) rather than NULL -- MeshCore's "never
// actually reported a GPS position" sentinel in practice, not a real fix
// off the coast of Ghana. Both the relay-hop-point lookup and the
// observer-position lookup must treat it as unpositioned (matching the
// same convention GetNodesForScopeAdoption and geofilter.PassesFilter
// already use), keeping the node's name but not its bogus position.
func TestGetPacketPath_ExcludesNullIsland(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsZero', 'Zero Observer', NULL)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkZero', 'ZeroRepeater', 'repeater', 0, 0)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('obsZero', 'Zero Observer', 'repeater', 0, 0)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000008', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkZero"]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000008")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 {
t.Fatalf("Branches = %+v, want 1", resp.Branches)
}
b := resp.Branches[0]
if len(b.Points) != 1 || b.Points[0].Name != "ZeroRepeater" {
t.Fatalf("Points = %+v, want ZeroRepeater still named", b.Points)
}
if b.Points[0].Lat != nil || b.Points[0].Lon != nil {
t.Errorf("Points[0].Lat/Lon = %v/%v, want nil -- (0,0) is a no-fix sentinel, not a real position", b.Points[0].Lat, b.Points[0].Lon)
}
if b.Observer == nil || b.Observer.Name != "Zero Observer" {
t.Fatalf("Observer = %+v, want Zero Observer still named", b.Observer)
}
if b.Observer.Lat != nil || b.Observer.Lon != nil {
t.Errorf("Observer.Lat/Lon = %v/%v, want nil -- the observer's own node row is also (0,0)", b.Observer.Lat, b.Observer.Lon)
}
}
// TestGetPacketPath_FallsBackToSingleNeighborPosition covers a hop and
// an observer that have no position of their own anywhere (no GPS, no
// name match, no IATA) but have exactly one neighbor_edges neighbor
// that IS positioned -- that neighbor's exact position should be
// borrowed as an approximate stand-in, flagged via Approx so callers
// don't mistake it for a real fix.
func TestGetPacketPath_FallsBackToSingleNeighborPosition(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (node_a TEXT NOT NULL, node_b TEXT NOT NULL, count INTEGER DEFAULT 1, last_seen TEXT, PRIMARY KEY (node_a, node_b))`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsghost', 'Ghost Observer', NULL)`)
// pkghost/obsghost: no lat/lon at all (unadvertised). pkanchor: their
// only positioned neighbor. Pubkeys lowercase throughout, matching
// real ingest data (and neighbor_edges' own storage/lookup casing) --
// resolved_path entries and neighbor_edges rows must agree on case
// for the IN() lookups to match.
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkghost', 'GhostRepeater', 'repeater')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('obsghost', 'Ghost Observer', 'repeater')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkanchor', 'AnchorRepeater', 'repeater', 55.5, 9.5)`)
db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count) VALUES ('pkanchor', 'pkghost', 10)`)
db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count) VALUES ('obsghost', 'pkanchor', 10)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000009', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkghost"]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000009")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 {
t.Fatalf("Branches = %+v, want 1", resp.Branches)
}
b := resp.Branches[0]
if len(b.Points) != 1 {
t.Fatalf("Points = %+v, want 1", b.Points)
}
p := b.Points[0]
if p.Name != "GhostRepeater" {
t.Errorf("Points[0].Name = %q, want GhostRepeater (its own name, not the neighbor's)", p.Name)
}
if !p.Approx {
t.Errorf("Points[0].Approx = false, want true -- position was borrowed from a neighbor")
}
if p.Lat == nil || *p.Lat != 55.5 || p.Lon == nil || *p.Lon != 9.5 {
t.Errorf("Points[0].Lat/Lon = %v/%v, want AnchorRepeater's exact position (55.5, 9.5) -- its only positioned neighbor", p.Lat, p.Lon)
}
if p.ApproxNeighborCount != 1 {
t.Errorf("Points[0].ApproxNeighborCount = %d, want 1 (only AnchorRepeater is positioned)", p.ApproxNeighborCount)
}
if p.ApproxSpreadKm != nil {
t.Errorf("Points[0].ApproxSpreadKm = %v, want nil/omitted -- spread is meaningless with a single contributor", p.ApproxSpreadKm)
}
if b.Observer == nil || b.Observer.Name != "Ghost Observer" {
t.Fatalf("Observer = %+v, want Ghost Observer still named", b.Observer)
}
if !b.Observer.Approx {
t.Errorf("Observer.Approx = false, want true")
}
if b.Observer.Lat == nil || *b.Observer.Lat != 55.5 || b.Observer.Lon == nil || *b.Observer.Lon != 9.5 {
t.Errorf("Observer.Lat/Lon = %v/%v, want AnchorRepeater's exact position (55.5, 9.5)", b.Observer.Lat, b.Observer.Lon)
}
if b.Observer.ApproxNeighborCount != 1 {
t.Errorf("Observer.ApproxNeighborCount = %d, want 1", b.Observer.ApproxNeighborCount)
}
}
// TestGetPacketPath_FallsBackToWeightedNeighborCentroid covers a hop
// with TWO positioned neighbors of different edge strength: the
// approximate position must be a count-weighted average of both real
// positions -- not just the stronger neighbor's exact coordinates --
// since each neighbor's own GPS is precise even though the hop's
// position relative to them isn't.
func TestGetPacketPath_FallsBackToWeightedNeighborCentroid(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (node_a TEXT NOT NULL, node_b TEXT NOT NULL, count INTEGER DEFAULT 1, last_seen TEXT, PRIMARY KEY (node_a, node_b))`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', NULL)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkghost', 'GhostRepeater', 'repeater')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkanchor', 'AnchorRepeater', 'repeater', 55.5, 9.5)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkweak', 'WeakRepeater', 'repeater', 60.0, 15.0)`)
// pkanchor is a 10x stronger edge than pkweak -- weighted centroid:
// lat = (55.5*10 + 60.0*1) / 11 = 55.90909..., lon = (9.5*10 + 15.0*1) / 11 = 10.0
db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count) VALUES ('pkanchor', 'pkghost', 10)`)
db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count) VALUES ('pkghost', 'pkweak', 1)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000010', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkghost"]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000010")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 || len(resp.Branches[0].Points) != 1 {
t.Fatalf("Branches = %+v, want 1 branch with 1 point", resp.Branches)
}
p := resp.Branches[0].Points[0]
if !p.Approx {
t.Fatalf("Approx = false, want true")
}
if p.Lat == nil || p.Lon == nil {
t.Fatalf("Lat/Lon = %v/%v, want a computed centroid, not nil", p.Lat, p.Lon)
}
const wantLat, wantLon = 55.90909090909091, 10.0
const epsilon = 1e-9
if diff := *p.Lat - wantLat; diff > epsilon || diff < -epsilon {
t.Errorf("Lat = %v, want weighted centroid %v (not AnchorRepeater's exact 55.5, since WeakRepeater also has a real position)", *p.Lat, wantLat)
}
if diff := *p.Lon - wantLon; diff > epsilon || diff < -epsilon {
t.Errorf("Lon = %v, want weighted centroid %v", *p.Lon, wantLon)
}
if p.ApproxNeighborCount != 2 {
t.Errorf("ApproxNeighborCount = %d, want 2 (AnchorRepeater + WeakRepeater)", p.ApproxNeighborCount)
}
if p.ApproxSpreadKm == nil || *p.ApproxSpreadKm < 100 {
t.Errorf("ApproxSpreadKm = %v, want a sizeable distance between AnchorRepeater (55.5,9.5) and WeakRepeater (60.0,15.0)", p.ApproxSpreadKm)
}
}
// TestGetPacketPath_ObserverPositionPrefersOwnGPS covers an observer whose
// configured IATA code isn't a real airport (a custom/regional code an
// operator typed in, or a typo) and so isn't in the hardcoded iataCoords
// table -- but the observer is itself a mesh node that has self-advertised
// a real GPS position. That position must be used instead of leaving the
// observer unplaced, since it's the same source /api/observers and the
// Wardriving tab already treat as authoritative.
func TestGetPacketPath_ObserverPositionPrefersOwnGPS(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('deadbeefcafe', 'Custom Coded Observer', 'QXV')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('deadbeefcafe', 'Custom Coded Observer', 'room', 56.19, 9.6)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000004', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '[]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000004")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 {
t.Fatalf("Branches = %+v, want 1", resp.Branches)
}
obs := resp.Branches[0].Observer
if obs == nil {
t.Fatalf("Observer = nil, want a populated observer")
}
if obs.IATA != "QXV" {
t.Errorf("Observer.IATA = %q, want QXV (kept even though it's not in iataCoords)", obs.IATA)
}
if obs.Lat == nil || *obs.Lat != 56.19 || obs.Lon == nil || *obs.Lon != 9.6 {
t.Errorf("Observer.Lat/Lon = %v/%v, want the node's own self-advertised GPS (56.19, 9.6), not left nil just because QXV isn't a known airport", obs.Lat, obs.Lon)
}
if obs.Role != "room" {
t.Errorf("Observer.Role = %q, want room (from its own nodes row)", obs.Role)
}
}
// TestGetPacketPath_ObserverPositionFallsBackToNameMatch covers a
// bridge-type observer (seen in the wild on openHop-Repeater firmware)
// whose `observers.id` is a human-readable device name rather than its
// mesh pubkey -- so the pubkey lookup can never find its real, positioned
// node row. The position should still be found by matching display name
// against `nodes` instead of leaving the observer unplaced.
func TestGetPacketPath_ObserverPositionFallsBackToNameMatch(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// observers.id is the device's NAME, not a pubkey -- the real pubkey
// only exists as nodes.public_key on a separate row.
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('DK_FRØRUP_5871_R0001', 'DK_FRØRUP_5871_R0001', 'REPEATER')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('226e9df0...real-pubkey', 'DK_FRØRUP_5871_R0001', 'repeater', 55.237344, 10.710082)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000005', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '[]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000005")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 {
t.Fatalf("Branches = %+v, want 1", resp.Branches)
}
obs := resp.Branches[0].Observer
if obs == nil {
t.Fatalf("Observer = nil, want a populated observer")
}
if obs.Lat == nil || *obs.Lat != 55.237344 || obs.Lon == nil || *obs.Lon != 10.710082 {
t.Errorf("Observer.Lat/Lon = %v/%v, want the name-matched node's GPS (55.237344, 10.710082)", obs.Lat, obs.Lon)
}
}
// TestGetPacketPath_ObserverPositionSkipsAmbiguousNameMatch covers two
// unrelated positioned nodes that happen to share a display name: the
// name-match fallback must not guess which one is the real observer, so
// the observer should stay unplaced (falling through to the IATA table,
// or nil if that has nothing either) rather than silently picking one.
func TestGetPacketPath_ObserverPositionSkipsAmbiguousNameMatch(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('bridge-id-not-a-pubkey', 'Duplicate Name', NULL)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkOne', 'Duplicate Name', 'repeater', 10.0, 20.0)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkTwo', 'Duplicate Name', 'repeater', 30.0, 40.0)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000006', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '[]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000006")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 {
t.Fatalf("Branches = %+v, want 1", resp.Branches)
}
obs := resp.Branches[0].Observer
if obs == nil {
t.Fatalf("Observer = nil, want a populated observer (just without a position)")
}
if obs.Lat != nil || obs.Lon != nil {
t.Errorf("Observer.Lat/Lon = %v/%v, want nil -- must not guess between two same-named nodes", obs.Lat, obs.Lon)
}
}
// TestGetPacketPath_NoResolvedPath covers a station whose observation
// never resolved: it still contributes a branch (hop count from
// path_json, so reach is never silently dropped), just with no points.
func TestGetPacketPath_NoResolvedPath(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000002', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000002")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 {
t.Fatalf("Branches = %+v, want 1 branch even though its path never resolved", resp.Branches)
}
b := resp.Branches[0]
if b.Hops != 1 {
t.Errorf("Branches[0].Hops = %d, want 1 (from path_json, independent of resolution)", b.Hops)
}
if len(b.Points) != 0 {
t.Errorf("Branches[0].Points = %+v, want empty when the path never resolved", b.Points)
}
if b.Observer == nil || b.Observer.Name != "Observer One" {
t.Errorf("Branches[0].Observer = %+v, want Observer One (who heard it is always known)", b.Observer)
}
}
// TestGetPacketPath_SameObserverMultipleObservations covers a station
// that heard the same packet more than once as later flood copies
// arrived via longer routes (the common case -- see the trace dump for
// any busy channel): only its single deepest observation should
// contribute a branch, not one branch per observation.
func TestGetPacketPath_SameObserverMultipleObservations(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkAlpha', 'RepeaterAlpha', 'repeater', 56.1, 10.2)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000003', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
// Direct copy arrives first (0 hops)...
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '[]', 1736935200)`)
// ...then a relayed copy arrives later, 1 hop.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 6.0, -100, '["aa"]', '["pkAlpha"]', 1736935260)`)
resp, err := db.GetPacketPath("pathtest00000003")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 1 {
t.Fatalf("Branches = %+v, want exactly 1 (same station, keep only its deepest observation)", resp.Branches)
}
if resp.Branches[0].Hops != 1 || len(resp.Branches[0].Points) != 1 {
t.Errorf("Branches[0] = %+v, want the 1-hop relayed observation, not the 0-hop direct one", resp.Branches[0])
}
}
func TestGetPacketPath_UnknownHash(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
resp, err := db.GetPacketPath("doesnotexist0000")
if err != nil {
t.Fatal(err)
}
if len(resp.Branches) != 0 {
t.Errorf("expected an empty response for an unknown hash, got %+v", resp)
}
}
func TestGetChannels(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
channels, err := db.GetChannels()
if err != nil {
t.Fatal(err)
}
if len(channels) != 1 {
t.Errorf("expected 1 channel, got %d", len(channels))
}
if channels[0]["name"] != "#test" {
t.Errorf("expected #test channel, got %v", channels[0]["name"])
}
}
func TestGetNetworkStatus(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
ht := HealthThresholds{
InfraDegradedHours: 24,
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
}
result, err := db.GetNetworkStatus(ht)
if err != nil {
t.Fatal(err)
}
total, _ := result["total"].(int)
if total != 3 {
t.Errorf("expected 3 total nodes, got %d", total)
}
}
func TestGetMaxTransmissionID(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
maxID := db.GetMaxTransmissionID()
if maxID != 3 {
t.Errorf("expected max ID 3, got %d", maxID)
}
}
func TestGetNewTransmissionsSince(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
txs, err := db.GetNewTransmissionsSince(0, 10)
if err != nil {
t.Fatal(err)
}
if len(txs) != 3 {
t.Errorf("expected 3 new transmissions, got %d", len(txs))
}
txs, err = db.GetNewTransmissionsSince(1, 10)
if err != nil {
t.Fatal(err)
}
if len(txs) != 2 {
t.Errorf("expected 2 new transmissions after ID 1, got %d", len(txs))
}
}
func TestGetTransmissionByIDFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
tx, err := db.GetTransmissionByID(1)
if err != nil {
t.Fatal(err)
}
if tx == nil {
t.Fatal("expected transmission, got nil")
}
if tx["hash"] != "abc123def4567890" {
t.Errorf("expected hash abc123def4567890, got %v", tx["hash"])
}
if tx["raw_hex"] != "AABB" {
t.Errorf("expected raw_hex AABB, got %v", tx["raw_hex"])
}
}
func TestGetTransmissionByIDNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
result, _ := db.GetTransmissionByID(9999)
if result != nil {
t.Error("expected nil result for nonexistent transmission")
}
}
func TestGetPacketByHashNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
result, _ := db.GetPacketByHash("nonexistenthash1")
if result != nil {
t.Error("expected nil result for nonexistent hash")
}
}
func TestGetObserverIdsForRegion(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
t.Run("with data", func(t *testing.T) {
ids, err := db.GetObserverIdsForRegion("SJC")
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 {
t.Errorf("expected 1 observer for SJC, got %d", len(ids))
}
if ids[0] != "obs1" {
t.Errorf("expected obs1, got %s", ids[0])
}
})
t.Run("multiple codes", func(t *testing.T) {
ids, err := db.GetObserverIdsForRegion("SJC,SFO")
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 {
t.Errorf("expected 2 observers, got %d", len(ids))
}
})
t.Run("case and trim normalization", func(t *testing.T) {
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs3', 'Observer Three', ' sjc ', ?, '2026-01-01T00:00:00Z', 1)`, time.Now().UTC().Format(time.RFC3339))
ids, err := db.GetObserverIdsForRegion(" sjc ")
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 {
t.Errorf("expected 2 observers for normalized sjc, got %d", len(ids))
}
})
t.Run("empty param", func(t *testing.T) {
ids, err := db.GetObserverIdsForRegion("")
if err != nil {
t.Fatal(err)
}
if ids != nil {
t.Error("expected nil for empty region")
}
})
t.Run("not found", func(t *testing.T) {
ids, err := db.GetObserverIdsForRegion("ZZZ")
if err != nil {
t.Fatal(err)
}
if len(ids) != 0 {
t.Errorf("expected 0 observers for ZZZ, got %d", len(ids))
}
})
}
func TestGetChannelMessages(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
t.Run("matching channel", func(t *testing.T) {
messages, total, err := db.GetChannelMessages("#test", 100, 0)
if err != nil {
t.Fatal(err)
}
if total == 0 {
t.Error("expected at least 1 message for #test")
}
if len(messages) == 0 {
t.Error("expected non-empty messages")
}
})
t.Run("non-matching channel", func(t *testing.T) {
messages, total, err := db.GetChannelMessages("#nonexistent", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 0 {
t.Errorf("expected 0 messages, got %d", total)
}
if len(messages) != 0 {
t.Errorf("expected empty messages, got %d", len(messages))
}
})
t.Run("default limit", func(t *testing.T) {
messages, _, err := db.GetChannelMessages("#test", 0, 0)
if err != nil {
t.Fatal(err)
}
if messages == nil {
t.Error("expected non-nil result")
}
})
}
func TestGetChannelMessagesRegionFiltering(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
ts1 := now.Add(-2 * time.Minute).Format(time.RFC3339)
ts2 := now.Add(-1 * time.Minute).Format(time.RFC3339)
epoch1 := now.Add(-2 * time.Minute).Unix()
epoch2 := now.Add(-1 * time.Minute).Unix()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', ' sfo ')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'chanregion0001', ?, 1, 5,
'{"type":"CHAN","channel":"#region","text":"SjcUser: One","sender":"SjcUser"}', '#region')`, ts1)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB', 'chanregion0002', ?, 1, 5,
'{"type":"CHAN","channel":"#region","text":"SfoUser: Two","sender":"SfoUser"}', '#region')`, ts2)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -90, '[]', ?)`, epoch1)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 2, 9.0, -91, '[]', ?)`, epoch2)
msgsSJC, totalSJC, err := db.GetChannelMessages("#region", 100, 0, " sjc ")
if err != nil {
t.Fatal(err)
}
if totalSJC != 1 || len(msgsSJC) != 1 {
t.Fatalf("expected 1 SJC message, total=%d len=%d", totalSJC, len(msgsSJC))
}
if msgsSJC[0]["sender"] != "SjcUser" {
t.Fatalf("expected SJC sender SjcUser, got %v", msgsSJC[0]["sender"])
}
msgsMulti, totalMulti, err := db.GetChannelMessages("#region", 100, 0, "sjc, SFO")
if err != nil {
t.Fatal(err)
}
if totalMulti != 2 || len(msgsMulti) != 2 {
t.Fatalf("expected 2 multi-region messages, total=%d len=%d", totalMulti, len(msgsMulti))
}
}
func TestBuildPacketWhereFilters(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
t.Run("type filter", func(t *testing.T) {
pt := 4
result, err := db.QueryPackets(PacketQuery{Limit: 50, Type: &pt, Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for type=4")
}
})
t.Run("route filter", func(t *testing.T) {
rt := 1
result, err := db.QueryPackets(PacketQuery{Limit: 50, Route: &rt, Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for route=1")
}
})
t.Run("observer filter", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{Limit: 50, Observer: "obs1", Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for observer=obs1")
}
})
t.Run("hash filter", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{Limit: 50, Hash: "abc123def4567890", Order: "DESC"})
if err != nil {
t.Fatal(err)
}
// 1 transmission with this hash (has 2 observations, but transmission-centric)
if result.Total != 1 {
t.Errorf("expected 1 result for hash filter, got %d", result.Total)
}
})
t.Run("since filter", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{Limit: 50, Since: "2020-01-01", Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for since filter")
}
})
t.Run("until filter", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{Limit: 50, Until: "2099-01-01", Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for until filter")
}
})
t.Run("region filter", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{Limit: 50, Region: "SJC", Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for region=SJC")
}
})
t.Run("node filter by name", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{Limit: 50, Node: "TestRepeater", Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for node=TestRepeater")
}
})
t.Run("node filter by pubkey", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{Limit: 50, Node: "aabbccdd11223344", Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for node pubkey filter")
}
})
t.Run("combined filters", func(t *testing.T) {
pt := 4
rt := 1
result, err := db.QueryPackets(PacketQuery{
Limit: 50,
Type: &pt,
Route: &rt,
Observer: "obs1",
Since: "2020-01-01",
Order: "DESC",
})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results with combined filters")
}
})
t.Run("default limit", func(t *testing.T) {
result, err := db.QueryPackets(PacketQuery{})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Error("expected non-nil result")
}
})
}
func TestResolveNodePubkey(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
t.Run("by pubkey", func(t *testing.T) {
pk := db.resolveNodePubkey("aabbccdd11223344")
if pk != "aabbccdd11223344" {
t.Errorf("expected aabbccdd11223344, got %s", pk)
}
})
t.Run("by name", func(t *testing.T) {
pk := db.resolveNodePubkey("TestRepeater")
if pk != "aabbccdd11223344" {
t.Errorf("expected aabbccdd11223344, got %s", pk)
}
})
t.Run("not found returns input", func(t *testing.T) {
pk := db.resolveNodePubkey("nonexistent")
if pk != "nonexistent" {
t.Errorf("expected 'nonexistent' back, got %s", pk)
}
})
}
func TestGetNodesFiltering(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
t.Run("role filter", func(t *testing.T) {
nodes, total, _, err := db.GetNodes(50, 0, "repeater", "", "", "", "", "")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 repeater, got %d", total)
}
if len(nodes) != 1 {
t.Errorf("expected 1 node, got %d", len(nodes))
}
})
t.Run("search filter", func(t *testing.T) {
nodes, _, _, err := db.GetNodes(50, 0, "", "Companion", "", "", "", "")
if err != nil {
t.Fatal(err)
}
if len(nodes) != 1 {
t.Errorf("expected 1 companion, got %d", len(nodes))
}
})
t.Run("sort by name", func(t *testing.T) {
nodes, _, _, err := db.GetNodes(50, 0, "", "", "", "", "name", "")
if err != nil {
t.Fatal(err)
}
if len(nodes) == 0 {
t.Error("expected nodes")
}
})
t.Run("sort by packetCount", func(t *testing.T) {
nodes, _, _, err := db.GetNodes(50, 0, "", "", "", "", "packetCount", "")
if err != nil {
t.Fatal(err)
}
if len(nodes) == 0 {
t.Error("expected nodes")
}
})
t.Run("sort by lastSeen", func(t *testing.T) {
nodes, _, _, err := db.GetNodes(50, 0, "", "", "", "", "lastSeen", "")
if err != nil {
t.Fatal(err)
}
if len(nodes) == 0 {
t.Error("expected nodes")
}
})
t.Run("lastHeard filter 30d", func(t *testing.T) {
// The filter works by computing since = now - 30d; seed data last_seen may or may not match.
// Just verify the filter runs without error.
_, _, _, err := db.GetNodes(50, 0, "", "", "", "30d", "", "")
if err != nil {
t.Fatal(err)
}
})
t.Run("lastHeard filter various", func(t *testing.T) {
for _, lh := range []string{"1h", "6h", "24h", "7d", "30d", "invalid"} {
_, _, _, err := db.GetNodes(50, 0, "", "", "", lh, "", "")
if err != nil {
t.Fatalf("lastHeard=%s failed: %v", lh, err)
}
}
})
t.Run("default limit", func(t *testing.T) {
nodes, _, _, err := db.GetNodes(0, 0, "", "", "", "", "", "")
if err != nil {
t.Fatal(err)
}
if len(nodes) == 0 {
t.Error("expected nodes with default limit")
}
})
t.Run("before filter", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "2026-01-02T00:00:00Z", "", "", "")
if err != nil {
t.Fatal(err)
}
if total != 3 {
t.Errorf("expected 3 nodes with first_seen <= 2026-01-02, got %d", total)
}
})
t.Run("offset", func(t *testing.T) {
nodes, total, _, err := db.GetNodes(1, 1, "", "", "", "", "", "")
if err != nil {
t.Fatal(err)
}
if total != 3 {
t.Errorf("expected 3 total, got %d", total)
}
if len(nodes) != 1 {
t.Errorf("expected 1 node with offset, got %d", len(nodes))
}
})
t.Run("region filter SJC", func(t *testing.T) {
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for SJC region, got %d", total)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0]["public_key"] != "aabbccdd11223344" {
t.Errorf("expected TestRepeater, got %v", nodes[0]["public_key"])
}
})
t.Run("region filter SFO", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SFO")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for SFO region, got %d", total)
}
})
t.Run("region filter multi", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC,SFO")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for SJC,SFO region, got %d", total)
}
})
t.Run("region filter unknown", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "AMS")
if err != nil {
t.Fatal(err)
}
if total != 0 {
t.Errorf("expected 0 nodes for unknown region, got %d", total)
}
})
}
// setupTestDBV2 creates an in-memory SQLite database with the v2 schema
// where observations use observer_id TEXT instead of observer_idx INTEGER.
func setupTestDBV2(t *testing.T) *DB {
t.Helper()
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
conn.SetMaxOpenConns(1)
schema := `
CREATE TABLE nodes (
public_key TEXT PRIMARY KEY,
name TEXT,
role TEXT,
lat REAL,
lon REAL,
last_seen TEXT,
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
);
CREATE TABLE observers (
id TEXT PRIMARY KEY,
name TEXT,
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL
);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
channel_hash TEXT DEFAULT NULL,
from_pubkey TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_id TEXT,
observer_name TEXT,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL,
raw_hex TEXT
);
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
}
return &DB{conn: conn, isV3: false}
}
func TestGetNodesRegionFilterV2(t *testing.T) {
db := setupTestDBV2(t)
defer db.Close()
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
recentEpoch := now.Add(-1 * time.Hour).Unix()
// Seed observer with IATA code
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs-v2-1', 'V2 Observer', 'LAX', ?, '2026-01-01T00:00:00Z', 10)`, recent)
// Seed a node
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('v2pubkey11223344', 'V2Node', 'repeater', 34.0, -118.0, ?, '2026-01-01T00:00:00Z', 5)`, recent)
// Seed an ADVERT transmission for the node
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('AABB', 'v2hash0001', ?, 1, 4, '{"pubKey":"v2pubkey11223344","name":"V2Node","type":"ADVERT"}')`, recent)
// Seed v2-style observation: observer_id references observers.id directly
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp)
VALUES (1, 'obs-v2-1', 'V2 Observer', 10.0, -90, '[]', ?)`, recentEpoch)
t.Run("v2 region filter match", func(t *testing.T) {
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "LAX")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for LAX region (v2 schema), got %d", total)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0]["public_key"] != "v2pubkey11223344" {
t.Errorf("expected V2Node, got %v", nodes[0]["public_key"])
}
})
t.Run("v2 region filter no match", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "JFK")
if err != nil {
t.Fatal(err)
}
if total != 0 {
t.Errorf("expected 0 nodes for JFK region (v2 schema), got %d", total)
}
})
}
func TestGetChannelMessagesDedup(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Seed observers
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`)
// Insert two transmissions with same hash to test dedup
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'chanmsg00000001', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#general","text":"User1: Hello","sender":"User1"}', '#general')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5,
'{"type":"CHAN","channel":"#general","text":"User2: World","sender":"User2"}', '#general')`)
// Observations: first msg seen by two observers (dedup), second by one
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 12.0, -90, '["aa"]', 1736935200)`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 10.0, -92, '["aa"]', 1736935210)`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 1, 14.0, -88, '[]', 1736935260)`)
messages, total, err := db.GetChannelMessages("#general", 100, 0)
if err != nil {
t.Fatal(err)
}
// Two unique messages (deduped by sender:hash)
if total < 2 {
t.Errorf("expected at least 2 unique messages, got %d", total)
}
if len(messages) < 2 {
t.Errorf("expected at least 2 messages, got %d", len(messages))
}
// Verify dedup: first message should have repeats > 1 because 2 observations
found := false
for _, m := range messages {
if m["text"] == "Hello" {
found = true
repeats, _ := m["repeats"].(int)
if repeats < 2 {
t.Errorf("expected repeats >= 2 for deduped msg, got %d", repeats)
}
}
}
if !found {
// Message text might be parsed differently
t.Log("Note: message text parsing may vary")
}
}
func TestGetChannelMessagesNoSender(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5,
'{"type":"CHAN","channel":"#noname","text":"plain text no colon"}', '#noname')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 12.0, -90, null, 1736935300)`)
messages, total, err := db.GetChannelMessages("#noname", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 message, got %d", total)
}
if len(messages) != 1 {
t.Errorf("expected 1 message, got %d", len(messages))
}
}
// TestGetChannelMessages_PingBotReply covers the CoreScope-only "ping"
// bot: a channel message whose text is exactly "ping" gets a synthetic
// botReply attached (never transmitted back onto the mesh -- see
// pingBotReply's doc comment), while ordinary messages don't.
func TestGetChannelMessages_PingBotReply(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`)
// pkBravoRepeater deliberately has NO nodes row -- exercises the
// unresolved-pubkey fallback (raw pubkey shown instead of a name).
// tx1: a plain chat message -- must NOT get a botReply.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'chanmsg00000001', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"just chatting","sender":"Alice"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '["aa","bb"]', 1736935200)`)
// tx2: bare "ping" -- must get a botReply with hops=2, snr=8.2, observer,
// and the relay path resolved to "RepeaterAlpha → pkBravoRepeater"
// (second hop has no nodes row, so its raw pubkey is shown instead).
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Bob"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (2, 1, 8.2, -90, '["aa","bb"]', '["pkAlphaRepeater","pkBravoRepeater"]', 1736935260)`)
// tx3: "@CoreScopeBot ping" -- the mention-prefix must be stripped before matching.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"@CoreScopeBot ping","sender":"Carol"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 1, 5.0, -95, '[]', 1736935320)`)
// tx4: "pinging" -- must NOT match (not an exact "ping").
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('DD', 'chanmsg00000004', '2026-01-15T10:03:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"pinging around","sender":"Dave"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (4, 1, 3.0, -99, '[]', 1736935380)`)
// tx5: "/ping" -- the slash-command form must trigger too.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"/ping","sender":"Frank"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (5, 1, 6.0, -91, '["aa"]', 1736935440)`)
messages, total, err := db.GetChannelMessages("#ping", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 5 {
t.Fatalf("expected 5 messages, got %d", total)
}
byText := map[string]map[string]interface{}{}
for _, m := range messages {
byText[m["text"].(string)] = m
}
if r := byText["just chatting"]["botReply"]; r != nil {
t.Errorf("plain chat message should not get a botReply, got %+v", r)
}
if r := byText["pinging around"]["botReply"]; r != nil {
t.Errorf("\"pinging\" should not match the exact \"ping\" trigger, got %+v", r)
}
pingReply, _ := byText["ping"]["botReply"].(map[string]interface{})
if pingReply == nil {
t.Fatal("bare \"ping\" message should get a botReply")
}
if pingReply["sender"] != "CoreScopeBot" {
t.Errorf("botReply sender = %v, want CoreScopeBot", pingReply["sender"])
}
if pingReply["hops"] != 2 {
t.Errorf("botReply hops = %v, want 2", pingReply["hops"])
}
replyText, _ := pingReply["text"].(string)
if !strings.Contains(replyText, "2 hops") || !strings.Contains(replyText, "8.2dB") || !strings.Contains(replyText, "Observer One") {
t.Errorf("botReply text = %q, want hops/SNR/observer mentioned", replyText)
}
if !strings.Contains(replyText, "via RepeaterAlpha → pkBravoRepeater") {
t.Errorf("botReply text = %q, want the resolved relay path (RepeaterAlpha for the known node, raw pubkey fallback for the unresolved one)", replyText)
}
mentionReply, _ := byText["@CoreScopeBot ping"]["botReply"].(map[string]interface{})
if mentionReply == nil {
t.Fatal("\"@CoreScopeBot ping\" should get a botReply (mention prefix stripped before matching)")
}
if mentionReply["hops"] != 0 {
t.Errorf("mention-prefixed ping botReply hops = %v, want 0 (empty path)", mentionReply["hops"])
}
slashReply, _ := byText["/ping"]["botReply"].(map[string]interface{})
if slashReply == nil {
t.Fatal("\"/ping\" should get a botReply -- it's in pingTriggerWords alongside bare \"ping\"")
}
if slashReply["sender"] != "CoreScopeBot" {
t.Errorf("\"/ping\" botReply sender = %v, want CoreScopeBot", slashReply["sender"])
}
}
// TestGetChannelMessages_PingBotReply_MultiObservation covers a single
// ping transmission heard by TWO different observers at
// DIFFERENT hop depths (normal in a mesh: one station may hear an early
// relay leg, another a later one). The botReply must report the DEEPEST
// (max-hop) observation's path/SNR -- not whichever observation happened
// to be scanned first -- and the breadth ("N observers") once more than
// one distinct station heard it, per pingBotReply's doc comment.
func TestGetChannelMessages_PingBotReply_MultiObservation(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkCharlieRepeater', 'RepeaterCharlie', 'repeater')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
// obs1 (scanned first, o.id=1): shallow leg, 1 hop. transmission_id=1
// since this is the first (only) transmission inserted in this fresh DB.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlphaRepeater"]', 1736935440)`)
// obs2 (scanned second, o.id=2): deeper leg, 3 hops -- must win despite
// being neither first nor having the highest SNR.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 2, 4.5, -99, '["aa","bb","cc"]', '["pkAlphaRepeater","pkBravoRepeater","pkCharlieRepeater"]', 1736935445)`)
messages, _, err := db.GetChannelMessages("#ping", 100, 0)
if err != nil {
t.Fatal(err)
}
var reply map[string]interface{}
for _, m := range messages {
if m["text"] == "ping" {
reply, _ = m["botReply"].(map[string]interface{})
}
}
if reply == nil {
t.Fatal("expected a botReply on the ping message")
}
if reply["hops"] != 3 {
t.Errorf("botReply hops = %v, want 3 (the deeper of the two observations)", reply["hops"])
}
text, _ := reply["text"].(string)
if !strings.Contains(text, "SNR 4.5dB") {
t.Errorf("botReply text = %q, want the SNR paired with the deeper (3-hop) observation, not the shallower one's 9.0dB", text)
}
if !strings.Contains(text, "via RepeaterAlpha → pkBravoRepeater → RepeaterCharlie") {
t.Errorf("botReply text = %q, want the deeper observation's resolved relay path", text)
}
if !strings.Contains(text, "heard by 2 observers") {
t.Errorf("botReply text = %q, want breadth reported as \"2 observers\" now that more than one observer heard it", text)
}
}
// TestGetChannelMessages_PingBotReply_SpreadDistance covers the "spread up
// to Nkm" part of the reply: when the first-hearing station and at least
// one other distinct station both have their own GPS fix on file (as
// mesh nodes, keyed by the observer's own pubkey), the farthest of those
// distances from the first hearer is reported -- the same "how wide did
// this spread" signal View Path's map shows.
func TestGetChannelMessages_PingBotReply_SpreadDistance(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('pkobsnear', 'Near Observer', 'CPH')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('pkobsfar', 'Far Observer', 'AAR')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkobsnear', 'Near Observer', 'repeater', 55.6761, 12.5683)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkobsfar', 'Far Observer', 'repeater', 56.1629, 10.2039)`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('FF', 'chanmsg00000006', '2026-01-15T10:05:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Grace"}', '#ping')`)
// pkObsNear (observer_idx=1) heard it first -- lower timestamp.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -85, '[]', 1736935500)`)
// pkObsFar (observer_idx=2) heard it a moment later.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 6.0, -95, '["aa"]', 1736935505)`)
messages, _, err := db.GetChannelMessages("#ping", 100, 0)
if err != nil {
t.Fatal(err)
}
var reply map[string]interface{}
for _, m := range messages {
if m["text"] == "ping" {
reply, _ = m["botReply"].(map[string]interface{})
}
}
if reply == nil {
t.Fatal("expected a botReply on the ping message")
}
text, _ := reply["text"].(string)
wantKm := haversineKm(55.6761, 12.5683, 56.1629, 10.2039)
wantFrag := fmt.Sprintf("spread up to %.1fkm", wantKm)
if !strings.Contains(text, wantFrag) {
t.Errorf("botReply text = %q, want %q (farthest distance from the first hearer)", text, wantFrag)
}
}
// TestGetChannelMessages_PingBotReply_NoSpreadWithoutPositions covers the
// omission side: when hearing stations don't have their own GPS fix on
// file (the common case -- most observers aren't also positioned mesh
// nodes), no "spread" claim should be fabricated.
func TestGetChannelMessages_PingBotReply_NoSpreadWithoutPositions(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`)
// Deliberately no nodes rows for obs1/obs2 -- neither has a GPS fix.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('GG', 'chanmsg00000007', '2026-01-15T10:06:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Heidi"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -85, '[]', 1736935600)`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 6.0, -95, '["aa"]', 1736935605)`)
messages, _, err := db.GetChannelMessages("#ping", 100, 0)
if err != nil {
t.Fatal(err)
}
var reply map[string]interface{}
for _, m := range messages {
if m["text"] == "ping" {
reply, _ = m["botReply"].(map[string]interface{})
}
}
if reply == nil {
t.Fatal("expected a botReply on the ping message")
}
text, _ := reply["text"].(string)
if strings.Contains(text, "spread up to") {
t.Errorf("botReply text = %q, want no \"spread up to\" claim when neither observer has a GPS fix", text)
}
}
func TestGetNetworkStatusDateFormats(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert nodes with different date formats
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen)
VALUES ('node1111', 'NodeRFC', 'repeater', ?)`, time.Now().Format(time.RFC3339))
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen)
VALUES ('node2222', 'NodeSQL', 'companion', ?)`, time.Now().Format("2006-01-02 15:04:05"))
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen)
VALUES ('node3333', 'NodeNull', 'room', NULL)`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen)
VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`)
ht := HealthThresholds{
InfraDegradedHours: 24,
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
}
result, err := db.GetNetworkStatus(ht)
if err != nil {
t.Fatal(err)
}
total, _ := result["total"].(int)
if total != 4 {
t.Errorf("expected 4 nodes, got %d", total)
}
// Verify the function handles all date formats without error
active, _ := result["active"].(int)
degraded, _ := result["degraded"].(int)
silent, _ := result["silent"].(int)
if active+degraded+silent != 4 {
t.Errorf("expected sum of statuses = 4, got %d", active+degraded+silent)
}
roleCounts, ok := result["roleCounts"].(map[string]int)
if !ok {
t.Fatal("expected roleCounts map")
}
if roleCounts["repeater"] != 1 {
t.Errorf("expected 1 repeater, got %d", roleCounts["repeater"])
}
}
func TestOpenDBValid(t *testing.T) {
// Create a real SQLite database file
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// Create DB with a table using a writable connection first
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT)`)
if err != nil {
conn.Close()
t.Fatal(err)
}
conn.Close()
// Now test OpenDB (read-only)
database, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB failed: %v", err)
}
defer database.Close()
// Verify it works
maxID := database.GetMaxTransmissionID()
if maxID != 0 {
t.Errorf("expected 0, got %d", maxID)
}
}
func TestOpenDBInvalidPath(t *testing.T) {
_, err := OpenDB(filepath.Join(t.TempDir(), "nonexistent", "sub", "dir", "test.db"))
if err == nil {
t.Error("expected error for invalid path")
}
}
// TestDetectSchemaScopeName verifies that OpenDB sets hasScopeName and
// hasDefaultScope via the real detectSchema path when the columns are present.
// The existing ScopeStats tests set these flags manually — this test ensures
// the flag-setting code itself is covered.
func TestDetectSchemaScopeName(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "detect.db")
// Create file-based DB with the scope_name and default_scope columns.
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
conn.SetMaxOpenConns(1)
if _, err := conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, scope_name TEXT)`); err != nil {
conn.Close()
t.Fatalf("create transmissions: %v", err)
}
if _, err := conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, default_scope TEXT)`); err != nil {
conn.Close()
t.Fatalf("create nodes: %v", err)
}
if _, err := conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY)`); err != nil {
conn.Close()
t.Fatalf("create observations: %v", err)
}
conn.Close()
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
if !db.hasScopeName {
t.Error("hasScopeName should be true when scope_name column exists")
}
if !db.hasDefaultScope {
t.Error("hasDefaultScope should be true when default_scope column exists")
}
// Verify the flags stay false when the columns are absent.
dbPath2 := filepath.Join(dir, "detect2.db")
conn2, err := sql.Open("sqlite", dbPath2)
if err != nil {
t.Fatal(err)
}
conn2.SetMaxOpenConns(1)
conn2.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT)`)
conn2.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY)`)
conn2.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY)`)
conn2.Close()
db2, err := OpenDB(dbPath2)
if err != nil {
t.Fatalf("OpenDB2: %v", err)
}
defer db2.Close()
if db2.hasScopeName {
t.Error("hasScopeName should be false when scope_name column is absent")
}
if db2.hasDefaultScope {
t.Error("hasDefaultScope should be false when default_scope column is absent")
}
}
// TestDetectSchemaWithRetryClosesMigrationRace reproduces the startup race
// found while testing #1865/#1867 live: the server and ingestor are
// separate processes started ~simultaneously, sharing one SQLite file, and
// the ingestor's ALTER TABLE migration can still be in flight when the
// server's one-time column detection first runs. Without a retry, a column
// added a few milliseconds after OpenDB's first PRAGMA scan would stay
// undetected for the server's entire lifetime. Here the column is added by
// a concurrent writer shortly after OpenDB starts, simulating exactly that
// race; detectSchemaWithRetry's short escalating-delay loop must still
// pick it up.
func TestDetectSchemaWithRetryClosesMigrationRace(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "race.db")
// Match the ingestor's real DSN (cmd/ingestor/db.go) -- WAL mode, so
// this writer and OpenDB's concurrent read-only connections behave
// like the real two-process deploy instead of hitting rollback-journal
// lock contention that wouldn't occur in production.
conn, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatal(err)
}
conn.SetMaxOpenConns(1)
defer conn.Close()
if _, err := conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT)`); err != nil {
t.Fatalf("create transmissions: %v", err)
}
if _, err := conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY)`); err != nil {
t.Fatalf("create nodes: %v", err)
}
if _, err := conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY)`); err != nil {
t.Fatalf("create observations: %v", err)
}
// Simulate the ingestor's migration landing ~10ms after the server
// opens its connection -- after OpenDB's first scan (t=0) but well
// before the retry loop's second scan (t=30ms), so it's reliably
// picked up regardless of scheduler jitter. Left open (closed via
// defer above) so the server's WAL checkpoint on Close doesn't race
// an already-closed writer -- that race is a test-harness artifact,
// not something that happens in production where the ingestor keeps
// running for the server's whole lifetime.
go func() {
time.Sleep(10 * time.Millisecond)
if _, err := conn.Exec(`ALTER TABLE nodes ADD COLUMN configured_scope TEXT`); err != nil {
t.Errorf("simulated migration ALTER TABLE failed: %v", err)
}
}()
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
if !db.hasConfiguredScope {
t.Error("hasConfiguredScope should be true -- detectSchemaWithRetry should have caught the column added shortly after the initial scan, not just the first PRAGMA snapshot")
}
}
func TestGetChannelMessagesObserverFallback(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Observer with ID but no name entry (observer_idx won't match)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'chanmsg00000004', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#obs","text":"Sender: Test","sender":"Sender"}', '#obs')`)
// Observation without observer (observer_idx = NULL)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, NULL, 12.0, -90, null, 1736935200)`)
messages, total, err := db.GetChannelMessages("#obs", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1, got %d", total)
}
if len(messages) != 1 {
t.Errorf("expected 1 message, got %d", len(messages))
}
}
func TestGetChannelsMultiple(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer', 'SJC')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'chan1hash', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#alpha","text":"Alice: Hello","sender":"Alice"}', '#alpha')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB', 'chan2hash', '2026-01-15T10:01:00Z', 1, 5,
'{"type":"CHAN","channel":"#beta","text":"Bob: World","sender":"Bob"}', '#beta')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('CC', 'chan3hash', '2026-01-15T10:02:00Z', 1, 5,
'{"type":"CHAN","channel":"","text":"No channel"}')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('DD', 'chan4hash', '2026-01-15T10:03:00Z', 1, 5,
'{"type":"OTHER"}')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('EE', 'chan5hash', '2026-01-15T10:04:00Z', 1, 5, 'not-valid-json')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 12.0, -90, null, 1736935200)`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 1, 12.0, -90, null, 1736935260)`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 1, 12.0, -90, null, 1736935320)`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (4, 1, 12.0, -90, null, 1736935380)`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (5, 1, 12.0, -90, null, 1736935440)`)
channels, err := db.GetChannels()
if err != nil {
t.Fatal(err)
}
// #alpha, #beta, and "unknown" (empty channel)
if len(channels) < 2 {
t.Errorf("expected at least 2 channels, got %d", len(channels))
}
}
func TestQueryGroupedPacketsWithFilters(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
rt := 1
result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50, Route: &rt})
if err != nil {
t.Fatal(err)
}
if result.Total == 0 {
t.Error("expected results for grouped with route filter")
}
}
func TestNullHelpers(t *testing.T) {
// nullStr
if nullStr(sql.NullString{Valid: false}) != nil {
t.Error("expected nil for invalid NullString")
}
if nullStr(sql.NullString{Valid: true, String: "hello"}) != "hello" {
t.Error("expected 'hello' for valid NullString")
}
// nullFloat
if nullFloat(sql.NullFloat64{Valid: false}) != nil {
t.Error("expected nil for invalid NullFloat64")
}
if nullFloat(sql.NullFloat64{Valid: true, Float64: 3.14}) != 3.14 {
t.Error("expected 3.14 for valid NullFloat64")
}
// nullInt
if nullInt(sql.NullInt64{Valid: false}) != nil {
t.Error("expected nil for invalid NullInt64")
}
if nullInt(sql.NullInt64{Valid: true, Int64: 42}) != 42 {
t.Error("expected 42 for valid NullInt64")
}
}
// TestGetChannelsStaleMessage verifies that GetChannels returns the newest message
// per channel even when an older message has a later observation timestamp.
// This is the regression test for #171.
func TestGetChannelsStaleMessage(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer1', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer2', 'SFO')`)
// Older message (first_seen T1)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'oldhash1', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#test","text":"Alice: Old message","sender":"Alice"}', '#test')`)
// Newer message (first_seen T2 > T1)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB', 'newhash2', '2026-01-15T10:05:00Z', 1, 5,
'{"type":"CHAN","channel":"#test","text":"Bob: New message","sender":"Bob"}', '#test')`)
// Observations: older message re-observed AFTER newer message (stale scenario)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, timestamp)
VALUES (1, 1, 12.0, -90, 1736935200)`) // old msg first obs
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, timestamp)
VALUES (2, 1, 14.0, -88, 1736935500)`) // new msg obs
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, timestamp)
VALUES (1, 2, 10.0, -95, 1736935800)`) // old msg re-observed LATER
channels, err := db.GetChannels()
if err != nil {
t.Fatal(err)
}
if len(channels) != 1 {
t.Fatalf("expected 1 channel, got %d", len(channels))
}
ch := channels[0]
if ch["lastMessage"] != "New message" {
t.Errorf("expected lastMessage='New message' (newest by first_seen), got %q", ch["lastMessage"])
}
if ch["lastSender"] != "Bob" {
t.Errorf("expected lastSender='Bob', got %q", ch["lastSender"])
}
if ch["messageCount"] != 2 {
t.Errorf("expected messageCount=2 (unique transmissions), got %v", ch["messageCount"])
}
}
func TestGetChannelsRegionFiltering(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer1', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer2', 'SFO')`)
// Channel message seen only in SJC
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'hash1', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#sjc-only","text":"Alice: Hello SJC","sender":"Alice"}', '#sjc-only')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, timestamp)
VALUES (1, 1, 12.0, -90, 1736935200)`)
// Channel message seen only in SFO
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB', 'hash2', '2026-01-15T10:05:00Z', 1, 5,
'{"type":"CHAN","channel":"#sfo-only","text":"Bob: Hello SFO","sender":"Bob"}', '#sfo-only')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, timestamp)
VALUES (2, 2, 14.0, -88, 1736935500)`)
// No region filter — both channels
all, err := db.GetChannels()
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("expected 2 channels without region filter, got %d", len(all))
}
// Filter SJC — only #sjc-only
sjc, err := db.GetChannels("SJC")
if err != nil {
t.Fatal(err)
}
if len(sjc) != 1 {
t.Fatalf("expected 1 channel for SJC, got %d", len(sjc))
}
if sjc[0]["name"] != "#sjc-only" {
t.Errorf("expected channel '#sjc-only', got %q", sjc[0]["name"])
}
// Filter SFO — only #sfo-only
sfo, err := db.GetChannels("SFO")
if err != nil {
t.Fatal(err)
}
if len(sfo) != 1 {
t.Fatalf("expected 1 channel for SFO, got %d", len(sfo))
}
if sfo[0]["name"] != "#sfo-only" {
t.Errorf("expected channel '#sfo-only', got %q", sfo[0]["name"])
}
}
func TestNodeTelemetryFields(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert node with telemetry data
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c)
VALUES ('pk_telem1', 'SensorNode', 'sensor', 37.0, -122.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 5, 3700, 28.5)`)
// Test via GetNodeByPubkey
node, err := db.GetNodeByPubkey("pk_telem1")
if err != nil {
t.Fatal(err)
}
if node == nil {
t.Fatal("expected node, got nil")
}
if node["battery_mv"] != 3700 {
t.Errorf("battery_mv=%v, want 3700", node["battery_mv"])
}
if node["temperature_c"] != 28.5 {
t.Errorf("temperature_c=%v, want 28.5", node["temperature_c"])
}
// Test via GetNodes
nodes, _, _, err := db.GetNodes(50, 0, "sensor", "", "", "", "", "")
if err != nil {
t.Fatal(err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 sensor node, got %d", len(nodes))
}
if nodes[0]["battery_mv"] != 3700 {
t.Errorf("GetNodes battery_mv=%v, want 3700", nodes[0]["battery_mv"])
}
// Test node without telemetry — fields should be nil
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count)
VALUES ('pk_notelem', 'PlainNode', 'repeater', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 3)`)
node2, _ := db.GetNodeByPubkey("pk_notelem")
if node2["battery_mv"] != nil {
t.Errorf("expected nil battery_mv for node without telemetry, got %v", node2["battery_mv"])
}
if node2["temperature_c"] != nil {
t.Errorf("expected nil temperature_c for node without telemetry, got %v", node2["temperature_c"])
}
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
func TestGetObserverMetrics(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
now := time.Now().UTC()
t1 := now.Add(-2 * time.Hour).Format(time.RFC3339)
t2 := now.Add(-1 * time.Hour).Format(time.RFC3339)
t3 := now.Format(time.RFC3339)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv) VALUES (?, ?, ?, ?, ?, ?, ?)",
"obs1", t1, -112.5, 100, 500, 3, 3720)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors) VALUES (?, ?, ?, ?, ?, ?)",
"obs1", t2, -110.0, 200, 800, 5)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors) VALUES (?, ?, ?, ?, ?, ?)",
"obs1", t3, -108.0, 300, 1100, 8)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
"obs2", t1, -115.0)
// Query all for obs1
since := now.Add(-3 * time.Hour).Format(time.RFC3339)
metrics, reboots, err := db.GetObserverMetrics("obs1", since, "", "5m", 3600)
if err != nil {
t.Fatal(err)
}
if len(metrics) != 3 {
t.Errorf("expected 3 metrics, got %d", len(metrics))
}
if len(reboots) != 0 {
t.Errorf("expected 0 reboots, got %d", len(reboots))
}
// Verify first row has noise_floor
if metrics[0].NoiseFloor == nil || *metrics[0].NoiseFloor != -112.5 {
t.Errorf("first noise_floor = %v, want -112.5", metrics[0].NoiseFloor)
}
// First row: no delta possible (first sample)
if metrics[0].TxAirtimePct != nil {
t.Errorf("first sample should have nil tx_airtime_pct, got %v", *metrics[0].TxAirtimePct)
}
// Second row should have computed deltas
// TX: (200-100) / 3600 * 100 ≈ 2.78%
if metrics[1].TxAirtimePct == nil {
t.Errorf("second sample tx_airtime_pct should not be nil")
} else if *metrics[1].TxAirtimePct < 2.0 || *metrics[1].TxAirtimePct > 3.5 {
t.Errorf("second sample tx_airtime_pct = %v, want ~2.78", *metrics[1].TxAirtimePct)
}
// Query with until filter
metrics2, _, err := db.GetObserverMetrics("obs1", since, t2, "5m", 3600)
if err != nil {
t.Fatal(err)
}
if len(metrics2) != 2 {
t.Errorf("expected 2 metrics with until filter, got %d", len(metrics2))
}
}
func TestGetMetricsSummary(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
now := time.Now().UTC()
t1 := now.Add(-2 * time.Hour).Format(time.RFC3339)
t2 := now.Add(-1 * time.Hour).Format(time.RFC3339)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, battery_mv) VALUES (?, ?, ?, ?)",
"obs1", t1, -112.0, 3720)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
"obs1", t2, -108.0)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
"obs2", t1, -115.0)
since := now.Add(-24 * time.Hour).Format(time.RFC3339)
summary, err := db.GetMetricsSummary(since)
if err != nil {
t.Fatal(err)
}
if len(summary) != 2 {
t.Fatalf("expected 2 observers in summary, got %d", len(summary))
}
// Results sorted by max_nf DESC
// obs1 has max -108, obs2 has max -115
if summary[0].ObserverID != "obs1" {
t.Errorf("first observer should be obs1 (highest max NF), got %s", summary[0].ObserverID)
}
if summary[0].CurrentNF == nil || *summary[0].CurrentNF != -108.0 {
t.Errorf("obs1 current NF = %v, want -108.0", summary[0].CurrentNF)
}
if summary[0].SampleCount != 2 {
t.Errorf("obs1 sample count = %d, want 2", summary[0].SampleCount)
}
// Verify sparkline data is included
if len(summary[0].Sparkline) != 2 {
t.Errorf("obs1 sparkline length = %d, want 2", len(summary[0].Sparkline))
}
if len(summary[1].Sparkline) != 1 {
t.Errorf("obs2 sparkline length = %d, want 1", len(summary[1].Sparkline))
}
// Sparkline should be ordered by timestamp ASC
if summary[0].Sparkline[0] != nil && *summary[0].Sparkline[0] != -112.0 {
t.Errorf("obs1 sparkline[0] = %v, want -112.0", *summary[0].Sparkline[0])
}
if summary[0].Sparkline[1] != nil && *summary[0].Sparkline[1] != -108.0 {
t.Errorf("obs1 sparkline[1] = %v, want -108.0", *summary[0].Sparkline[1])
}
}
func TestObserverMetricsAPIEndpoints(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
now := time.Now().UTC()
t1 := now.Add(-1 * time.Hour).Format(time.RFC3339)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
"obs1", t1, -112.0)
// Query directly to verify
metrics, _, err := db.GetObserverMetrics("obs1", "", "", "5m", 300)
if err != nil {
t.Fatal(err)
}
if len(metrics) != 1 {
t.Errorf("expected 1 metric, got %d", len(metrics))
}
}
func TestComputeDeltas(t *testing.T) {
intPtr := func(v int) *int { return &v }
floatPtr := func(v float64) *float64 { return &v }
t.Run("empty input", func(t *testing.T) {
result, reboots, err := computeDeltas(nil, 300)
if err != nil {
t.Fatal(err)
}
if result != nil {
t.Errorf("expected nil, got %v", result)
}
if reboots != nil {
t.Errorf("expected nil reboots, got %v", reboots)
}
})
t.Run("normal delta computation", func(t *testing.T) {
raw := []rawMetricsSample{
{Timestamp: "2026-04-05T00:00:00Z", NoiseFloor: floatPtr(-112), TxAirSecs: intPtr(100), RxAirSecs: intPtr(500), RecvErrors: intPtr(3), PacketsRecv: intPtr(1000)},
{Timestamp: "2026-04-05T00:05:00Z", NoiseFloor: floatPtr(-110), TxAirSecs: intPtr(115), RxAirSecs: intPtr(525), RecvErrors: intPtr(5), PacketsRecv: intPtr(1100)},
}
result, reboots, err := computeDeltas(raw, 300)
if err != nil {
t.Fatal(err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
if len(reboots) != 0 {
t.Errorf("expected 0 reboots, got %d", len(reboots))
}
// First sample: no deltas
if result[0].TxAirtimePct != nil {
t.Errorf("first sample should have nil tx_airtime_pct")
}
// Second sample: TX delta = 15 secs / 300 secs * 100 = 5%
if result[1].TxAirtimePct == nil {
t.Fatal("second sample tx_airtime_pct should not be nil")
}
if *result[1].TxAirtimePct != 5.0 {
t.Errorf("tx_airtime_pct = %v, want 5.0", *result[1].TxAirtimePct)
}
// RX delta = 25 secs / 300 secs * 100 ≈ 8.33%
if result[1].RxAirtimePct == nil {
t.Fatal("second sample rx_airtime_pct should not be nil")
}
if *result[1].RxAirtimePct < 8.3 || *result[1].RxAirtimePct > 8.4 {
t.Errorf("rx_airtime_pct = %v, want ~8.33", *result[1].RxAirtimePct)
}
// Error rate: delta_errors=2, delta_recv=100, rate = 2/(100+2)*100 ≈ 1.96%
if result[1].RecvErrorRate == nil {
t.Fatal("second sample recv_error_rate should not be nil")
}
if *result[1].RecvErrorRate < 1.9 || *result[1].RecvErrorRate > 2.0 {
t.Errorf("recv_error_rate = %v, want ~1.96", *result[1].RecvErrorRate)
}
})
t.Run("reboot detection", func(t *testing.T) {
raw := []rawMetricsSample{
{Timestamp: "2026-04-05T00:00:00Z", TxAirSecs: intPtr(1000), RxAirSecs: intPtr(5000)},
{Timestamp: "2026-04-05T00:05:00Z", TxAirSecs: intPtr(10), RxAirSecs: intPtr(20)}, // reboot!
{Timestamp: "2026-04-05T00:10:00Z", TxAirSecs: intPtr(25), RxAirSecs: intPtr(45)},
}
result, reboots, err := computeDeltas(raw, 300)
if err != nil {
t.Fatal(err)
}
if len(reboots) != 1 {
t.Fatalf("expected 1 reboot, got %d", len(reboots))
}
if reboots[0] != "2026-04-05T00:05:00Z" {
t.Errorf("reboot timestamp = %s", reboots[0])
}
if !result[1].IsReboot {
t.Error("second sample should be marked as reboot")
}
// Reboot sample should have nil deltas
if result[1].TxAirtimePct != nil {
t.Error("reboot sample should have nil tx_airtime_pct")
}
// Third sample should have valid deltas from post-reboot baseline
if result[2].TxAirtimePct == nil {
t.Fatal("third sample tx_airtime_pct should not be nil")
}
if *result[2].TxAirtimePct != 5.0 { // 15/300*100
t.Errorf("third sample tx_airtime_pct = %v, want 5.0", *result[2].TxAirtimePct)
}
})
t.Run("gap detection", func(t *testing.T) {
raw := []rawMetricsSample{
{Timestamp: "2026-04-05T00:00:00Z", TxAirSecs: intPtr(100)},
{Timestamp: "2026-04-05T00:15:00Z", TxAirSecs: intPtr(200)}, // 15min gap > 2*300s
}
result, _, err := computeDeltas(raw, 300)
if err != nil {
t.Fatal(err)
}
// Gap sample should have nil deltas
if result[1].TxAirtimePct != nil {
t.Error("gap sample should have nil tx_airtime_pct")
}
})
}
func TestGetObserverMetricsResolution(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs) VALUES (?, ?, ?, ?)",
"obs1", "2026-04-05T00:00:00Z", -112.0, 100)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs) VALUES (?, ?, ?, ?)",
"obs1", "2026-04-05T00:05:00Z", -110.0, 200)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs) VALUES (?, ?, ?, ?)",
"obs1", "2026-04-05T01:00:00Z", -108.0, 500)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs) VALUES (?, ?, ?, ?)",
"obs1", "2026-04-05T01:05:00Z", -106.0, 600)
// 5m resolution: all 4 rows
m5, _, err := db.GetObserverMetrics("obs1", "2026-04-04T00:00:00Z", "", "5m", 300)
if err != nil {
t.Fatal(err)
}
if len(m5) != 4 {
t.Errorf("5m resolution: expected 4 rows, got %d", len(m5))
}
// 1h resolution: 2 buckets
m1h, _, err := db.GetObserverMetrics("obs1", "2026-04-04T00:00:00Z", "", "1h", 300)
if err != nil {
t.Fatal(err)
}
if len(m1h) != 2 {
t.Errorf("1h resolution: expected 2 rows, got %d", len(m1h))
}
// 1d resolution: 1 bucket
m1d, _, err := db.GetObserverMetrics("obs1", "2026-04-04T00:00:00Z", "", "1d", 300)
if err != nil {
t.Fatal(err)
}
if len(m1d) != 1 {
t.Errorf("1d resolution: expected 1 row, got %d", len(m1d))
}
}
func TestHourlyResolutionDeltasNotNull(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
// Two hourly buckets, each with one sample. With old MAX+hardcoded gap threshold,
// the 3600s gap would exceed sampleInterval*2 (600s) and deltas would be null.
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, packets_sent, packets_recv) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"obs_hr", "2026-04-05T10:00:00Z", -110.0, 100, 200, 5, 50, 100)
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, packets_sent, packets_recv) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"obs_hr", "2026-04-05T11:00:00Z", -108.0, 200, 400, 10, 80, 200)
m, _, err := db.GetObserverMetrics("obs_hr", "2026-04-04T00:00:00Z", "", "1h", 300)
if err != nil {
t.Fatal(err)
}
if len(m) != 2 {
t.Fatalf("expected 2 rows, got %d", len(m))
}
// Second row should have computed deltas (not null)
if m[1].TxAirtimePct == nil {
t.Error("1h resolution: tx_airtime_pct should not be nil — gap threshold must scale with resolution")
}
}
func TestLastValuePreservesReboot(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
// Hour bucket with two samples: pre-reboot (high) and post-reboot (low).
// With MAX(), the pre-reboot value wins and the reboot is hidden.
// With LAST (latest timestamp), the post-reboot value wins.
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, packets_sent, packets_recv) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"obs_rb", "2026-04-05T10:00:00Z", -110.0, 1000, 2000, 500, 400, 800) // pre-reboot baseline
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, packets_sent, packets_recv) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"obs_rb", "2026-04-05T10:20:00Z", -110.0, 5000, 6000, 900, 700, 1200) // pre-reboot peak
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, packets_sent, packets_recv) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"obs_rb", "2026-04-05T10:40:00Z", -110.0, 10, 20, 1, 5, 10) // post-reboot (counter reset)
// Next hour bucket
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, packets_sent, packets_recv) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"obs_rb", "2026-04-05T11:00:00Z", -108.0, 100, 120, 5, 20, 50)
m, reboots, err := db.GetObserverMetrics("obs_rb", "2026-04-04T00:00:00Z", "", "1h", 300)
if err != nil {
t.Fatal(err)
}
if len(m) != 2 {
t.Fatalf("expected 2 rows, got %d", len(m))
}
// First bucket should use the LAST value (post-reboot: tx_air_secs=10).
// Second bucket (tx_air_secs=100) is a normal increase from 10→100.
// With LAST-value semantics, the second bucket should have valid deltas (not a reboot).
// With MAX(), first bucket would have tx_air_secs=5000, and second=100 would
// trigger a false reboot detection.
if m[1].IsReboot {
t.Error("second bucket should NOT be flagged as reboot with LAST-value aggregation")
}
if m[1].TxAirtimePct == nil {
t.Error("second bucket should have non-nil tx_airtime_pct")
}
_ = reboots // reboots list is informational
}
func TestParseWindowDuration(t *testing.T) {
tests := []struct {
input string
want time.Duration
err bool
}{
{"1h", time.Hour, false},
{"24h", 24 * time.Hour, false},
{"3d", 3 * 24 * time.Hour, false},
{"30d", 30 * 24 * time.Hour, false},
{"invalid", 0, true},
}
for _, tc := range tests {
got, err := parseWindowDuration(tc.input)
if tc.err && err == nil {
t.Errorf("parseWindowDuration(%q) expected error", tc.input)
}
if !tc.err && got != tc.want {
t.Errorf("parseWindowDuration(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
// TestPerObservationRawHexEnrich verifies enrichObs returns per-observation raw_hex
// when available, falling back to transmission raw_hex when NULL (#881).
func TestPerObservationRawHexEnrich(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert observers
db.conn.Exec(`INSERT INTO observers (id, name) VALUES ('obs-a', 'Observer A')`)
db.conn.Exec(`INSERT INTO observers (id, name) VALUES ('obs-b', 'Observer B')`)
var rowA, rowB int64
db.conn.QueryRow(`SELECT rowid FROM observers WHERE id='obs-a'`).Scan(&rowA)
db.conn.QueryRow(`SELECT rowid FROM observers WHERE id='obs-b'`).Scan(&rowB)
// Insert transmission with raw_hex
txHex := "deadbeef"
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen) VALUES (?, 'hash1', '2026-04-21T10:00:00Z')`, txHex)
// Insert two observations: A has its own raw_hex, B has NULL (historical)
obsAHex := "c0ffee01"
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, raw_hex)
VALUES (1, ?, -5.0, -90.0, '[]', 1745236800, ?)`, rowA, obsAHex)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, ?, -3.0, -85.0, '["aabb"]', 1745236801)`, rowB)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store load: %v", err)
}
tx := store.byHash["hash1"]
if tx == nil {
t.Fatal("transmission not loaded")
}
if len(tx.Observations) < 2 {
t.Fatalf("expected 2 observations, got %d", len(tx.Observations))
}
// Check enriched observations
for _, obs := range tx.Observations {
m := store.enrichObs(obs)
rh, _ := m["raw_hex"].(string)
if obs.RawHex != "" {
// Observer A: should get per-observation raw_hex
if rh != obsAHex {
t.Errorf("obs with own raw_hex: got %q, want %q", rh, obsAHex)
}
} else {
// Observer B: should fall back to transmission raw_hex
if rh != txHex {
t.Errorf("obs without raw_hex: got %q, want %q (tx fallback)", rh, txHex)
}
}
}
}
func TestGetScopeStats(t *testing.T) {
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
conn.SetMaxOpenConns(1)
db := &DB{conn: conn}
defer db.conn.Close()
// Create minimal schema
db.conn.Exec(`CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER,
payload_type INTEGER, payload_version INTEGER, decoded_json TEXT,
scope_name TEXT DEFAULT NULL
)`)
// Manually set hasScopeName since we bypassed the detector
db.hasScopeName = true
now := time.Now().UTC().Format(time.RFC3339)
// Transport scoped, known region
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('a', ?, 0, '#belgium')`, now)
// Transport scoped, unknown
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('b', ?, 0, '')`, now)
// Transport unscoped (NULL)
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('c', ?, 0, NULL)`, now)
// Non-transport FLOOD (route_type=1) — inherently unscoped per MeshCore protocol (#1838)
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('d', ?, 1, NULL)`, now)
// Non-transport DIRECT (route_type=2) — inherently unscoped per MeshCore protocol (#1838)
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('e', ?, 2, NULL)`, now)
stats, err := db.GetScopeStats("24h")
if err != nil {
t.Fatalf("GetScopeStats: %v", err)
}
// TransportTotal still counts only routes 0+3 (transport-code-carrying).
if stats.Summary.TransportTotal != 3 {
t.Errorf("TransportTotal = %d, want 3", stats.Summary.TransportTotal)
}
if stats.Summary.Scoped != 2 {
t.Errorf("Scoped = %d, want 2", stats.Summary.Scoped)
}
// Unscoped now folds in non-transport (routes 1+2) — see #1838.
// 1 transport-NULL + 2 non-transport (routes 1 and 2) = 3.
if stats.Summary.Unscoped != 3 {
t.Errorf("Unscoped = %d, want 3 (1 transport-null + 2 non-transport)", stats.Summary.Unscoped)
}
if stats.Summary.UnknownScope != 1 {
t.Errorf("UnknownScope = %d, want 1", stats.Summary.UnknownScope)
}
if len(stats.ByRegion) != 1 || stats.ByRegion[0].Name != "#belgium" || stats.ByRegion[0].Count != 1 {
t.Errorf("ByRegion = %+v, want [{#belgium 1}]", stats.ByRegion)
}
}
// TestLoadIndexesRelayHopsFromResolvedPath verifies that after Load(), relay
// nodes that appear only in resolved_path (not in decoded_json) are indexed
// in byNode. Regression for #692: indexByNode was called before observations
// were appended, so tx.ResolvedPath was nil at index time — #806 fixed this
// by indexing inline during the scan, this test locks it in.
func TestLoadIndexesRelayHopsFromResolvedPath(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
epoch := time.Now().UTC().Add(-1 * time.Hour).Unix()
// Insert a node whose pubkey does NOT appear in any decoded_json —
// it only relays traffic (appears in resolved_path of other packets).
const relayPubkey = "relay000aabbccddeeff0011"
const senderPubkey = "sender00112233445566"
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('FF01', 'relaytest0001hash', ?, 1, 4, ?)`,
now, `{"pubKey":"`+senderPubkey+`","name":"Sender","type":"ADVERT"}`)
// Observer hears the packet via the relay node.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (1, 1, 10.0, -90, '["rr"]', ?, ?)`,
epoch, `["`+relayPubkey+`"]`)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatal(err)
}
// The sender should be in byNode via decoded_json.
if len(store.byNode[senderPubkey]) == 0 {
t.Errorf("sender not indexed in byNode via decoded_json")
}
// The relay node must be in byNode via resolved_path — this was the bug.
if len(store.byNode[relayPubkey]) == 0 {
t.Errorf("relay node not indexed in byNode after Load() — resolved_path indexing broken")
}
if store.byNode[relayPubkey][0].Hash != "relaytest0001hash" {
t.Errorf("relay byNode entry has wrong hash: %s", store.byNode[relayPubkey][0].Hash)
}
}
func TestComputeScopeAdoptionByArea(t *testing.T) {
f := func(v float64) *float64 { return &v }
areas := map[string]AreaEntry{
"ODE": {Label: "Odense by", RegionScopes: []string{"dk-fyn-odense"}, LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)},
"GOT": {Label: "Göteborg, SE", LatMin: f(57.35), LatMax: f(57.90), LonMin: f(11.85), LonMax: f(12.85)}, // no RegionScopes link
}
nodes := []nodeAreaScopeInput{
{Lat: 55.4047, Lon: 10.3810, DefaultScope: "#dk-fyn-odense"}, // Odense, matches area's own region
{Lat: 55.40, Lon: 10.40, DefaultScope: "#dk-aarhus"}, // Odense, but a DIFFERENT region
{Lat: 55.41, Lon: 10.41, DefaultScope: ""}, // Odense, no scope at all
{Lat: 57.68, Lon: 11.97, DefaultScope: "#dk-aarhus"}, // Göteborg, has a scope, but area has no RegionScopes link
{Lat: 57.70, Lon: 11.98, DefaultScope: ""}, // Göteborg, no scope
{Lat: 0, Lon: 0, DefaultScope: "#dk-aarhus"}, // no-fix, must be excluded entirely
{Lat: 51.0, Lon: 4.0, DefaultScope: "#belgium"}, // outside every configured area, excluded
}
got := computeScopeAdoptionByArea(nodes, areas, nil)
if len(got) != 2 {
t.Fatalf("got %d areas, want 2 (ODE and GOT) -- result: %+v", len(got), got)
}
byKey := map[string]AreaScopeAdoption{}
for _, a := range got {
byKey[a.AreaKey] = a
}
ode := byKey["ODE"]
if ode.TotalNodes != 3 {
t.Errorf("ODE.TotalNodes = %d, want 3", ode.TotalNodes)
}
if ode.NodesWithAnyScope != 2 {
t.Errorf("ODE.NodesWithAnyScope = %d, want 2 (one has no scope at all)", ode.NodesWithAnyScope)
}
if ode.NodesMatchingArea != 1 {
t.Errorf("ODE.NodesMatchingArea = %d, want 1 (only the dk-fyn-odense one matches, the dk-aarhus one doesn't)", ode.NodesMatchingArea)
}
if len(ode.Matching) != 1 || len(ode.NotMatching) != 2 {
t.Errorf("ODE.Matching=%v NotMatching=%v, want 1 matching + 2 not-matching (RegionScopes is set)", ode.Matching, ode.NotMatching)
}
got2 := byKey["GOT"]
if got2.TotalNodes != 2 {
t.Errorf("GOT.TotalNodes = %d, want 2", got2.TotalNodes)
}
if got2.NodesWithAnyScope != 1 {
t.Errorf("GOT.NodesWithAnyScope = %d, want 1", got2.NodesWithAnyScope)
}
if got2.NodesMatchingArea != 0 {
t.Errorf("GOT.NodesMatchingArea = %d, want 0 (area has no RegionScopes link to match against)", got2.NodesMatchingArea)
}
if len(got2.Matching) != 0 || len(got2.NotMatching) != 0 {
t.Errorf("GOT.Matching=%v NotMatching=%v, want both empty (no RegionScopes, nothing to split into two groups)", got2.Matching, got2.NotMatching)
}
}
// TestComputeScopeAdoptionByArea_RelayedRegionCounts covers the case
// dborup flagged directly: a repeater sitting inside the Horsens area that
// has RELAYED dk-horsens traffic supports that region, even if its own
// default_scope is something else (or unset entirely) — matching must not
// be limited to default_scope, same runs-this-region vs
// carried-this-region's-traffic distinction as RepeatersByRegion vs
// OriginatingNodesByRegion elsewhere in this file.
func TestComputeScopeAdoptionByArea_RelayedRegionCounts(t *testing.T) {
f := func(v float64) *float64 { return &v }
areas := map[string]AreaEntry{
"HORSENS": {Label: "Horsens", RegionScopes: []string{"dk-horsens"}, LatMin: f(55.76), LatMax: f(55.94), LonMin: f(9.6), LonMax: f(9.96)},
}
nodes := []nodeAreaScopeInput{
{PublicKey: "relayer01", Lat: 55.85, Lon: 9.85, DefaultScope: "#dk"}, // own scope is the generic #dk, NOT dk-horsens
{PublicKey: "plainnode1", Lat: 55.86, Lon: 9.86, DefaultScope: ""}, // no scope, no relay activity either
{PublicKey: "relayerother", Lat: 55.87, Lon: 9.87, DefaultScope: ""}, // relays something, but not dk-horsens
}
relayInfo := map[string]RepeaterRelayInfo{
"relayer01": {TransportedScopes: []string{"#dk-horsens"}},
"relayerother": {TransportedScopes: []string{"#dk-aarhus"}},
}
got := computeScopeAdoptionByArea(nodes, areas, relayInfo)
if len(got) != 1 {
t.Fatalf("got %d areas, want 1", len(got))
}
h := got[0]
if h.TotalNodes != 3 {
t.Errorf("TotalNodes = %d, want 3", h.TotalNodes)
}
// relayer01 (relays dk-horsens) and relayerother (relays something,
// just not dk-horsens) both "use scope" in some sense; plainnode1 does
// nothing at all.
if h.NodesWithAnyScope != 2 {
t.Errorf("NodesWithAnyScope = %d, want 2", h.NodesWithAnyScope)
}
// Only relayer01 specifically relays THIS area's own region
// (dk-horsens) -- despite its own default_scope being the unrelated,
// generic #dk.
if h.NodesMatchingArea != 1 {
t.Errorf("NodesMatchingArea = %d, want 1 (relayer01 relays dk-horsens even though its default_scope is #dk)", h.NodesMatchingArea)
}
if len(h.Matching) != 1 || h.Matching[0].PublicKey != "relayer01" {
t.Errorf("Matching = %v, want just relayer01", h.Matching)
}
if len(h.NotMatching) != 2 {
t.Errorf("NotMatching = %v, want 2 (plainnode1 and relayerother)", h.NotMatching)
}
}
// TestComputeScopeAdoptionByArea_RollsUpIntoBroaderAreas is a regression
// test for exactly what dborup flagged live: "Danmark (alle)" showed only
// a handful of nodes because AreaKeyForPoint's single-most-specific-match
// meant every node already claimed by a smaller sub-area (e.g. "Odense
// by") never counted toward the broader containing area at all. A node
// inside a nested area must now count toward EVERY containing area, so
// DK's totals genuinely reflect the whole country, not just leftovers.
func TestComputeScopeAdoptionByArea_RollsUpIntoBroaderAreas(t *testing.T) {
f := func(v float64) *float64 { return &v }
areas := map[string]AreaEntry{
"DK": {Label: "Danmark (alle)", RegionScopes: []string{"dk"}, LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.25)},
"ODE": {Label: "Odense by", RegionScopes: []string{"dk-fyn-odense"}, LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)},
}
nodes := []nodeAreaScopeInput{
{PublicKey: "odenode01", Name: "OdenseNode", Lat: 55.4047, Lon: 10.3810, DefaultScope: "#dk-fyn-odense"}, // inside BOTH DK and ODE
{PublicKey: "dkonly01", Name: "SomewhereElseInDK", Lat: 56.0, Lon: 9.0, DefaultScope: "#dk"}, // inside DK only, not ODE
}
got := computeScopeAdoptionByArea(nodes, areas, nil)
byKey := map[string]AreaScopeAdoption{}
for _, a := range got {
byKey[a.AreaKey] = a
}
dk := byKey["DK"]
if dk.TotalNodes != 2 {
t.Errorf("DK.TotalNodes = %d, want 2 (both nodes fall inside DK's box, including the one also inside ODE)", dk.TotalNodes)
}
// The Odense node's own scope is dk-fyn-odense, not dk -- so it does
// NOT match DK's own region even though it geographically counts
// toward DK's totals. Only dkonly01 (#dk) matches.
if dk.NodesMatchingArea != 1 {
t.Errorf("DK.NodesMatchingArea = %d, want 1 (only dkonly01 actually uses #dk)", dk.NodesMatchingArea)
}
ode := byKey["ODE"]
if ode.TotalNodes != 1 {
t.Errorf("ODE.TotalNodes = %d, want 1 (only the Odense-positioned node)", ode.TotalNodes)
}
if ode.NodesMatchingArea != 1 {
t.Errorf("ODE.NodesMatchingArea = %d, want 1", ode.NodesMatchingArea)
}
}
func TestComputeScopeAdoptionByArea_Empty(t *testing.T) {
got := computeScopeAdoptionByArea(nil, map[string]AreaEntry{"DK": {Label: "Danmark"}}, nil)
if len(got) != 0 {
t.Errorf("expected no areas with 0 nodes, got %+v", got)
}
}
// TestComputeScopeAdoptionByArea_MultipleRegionScopes covers dborup's exact
// request: a broad umbrella area (Europa) can be linked to more than one
// hashRegions scope at once (e.g. both "eu" and "europe"), and a node using
// EITHER counts as matching -- not just the first-configured one.
func TestComputeScopeAdoptionByArea_MultipleRegionScopes(t *testing.T) {
f := func(v float64) *float64 { return &v }
areas := map[string]AreaEntry{
"EU": {Label: "Europa", RegionScopes: []string{"eu", "europe"}, LatMin: f(34.0), LatMax: f(71.5), LonMin: f(-25.0), LonMax: f(45.0)},
}
nodes := []nodeAreaScopeInput{
{PublicKey: "usesEu", Lat: 48.0, Lon: 10.0, DefaultScope: "#eu"},
{PublicKey: "usesEurope", Lat: 48.0, Lon: 11.0, DefaultScope: "#europe"},
{PublicKey: "relaysEurope", Lat: 48.0, Lon: 12.0, DefaultScope: ""},
{PublicKey: "usesNeither", Lat: 48.0, Lon: 13.0, DefaultScope: "#dk"},
// Own scope is #eu AND it also relays #europe -- must be
// reported as matching BOTH, not just the first one found.
{PublicKey: "usesBoth", Lat: 48.0, Lon: 14.0, DefaultScope: "#eu"},
}
relayInfo := map[string]RepeaterRelayInfo{
"relaysEurope": {TransportedScopes: []string{"#europe"}},
"usesBoth": {TransportedScopes: []string{"#europe"}},
}
got := computeScopeAdoptionByArea(nodes, areas, relayInfo)
if len(got) != 1 {
t.Fatalf("got %d areas, want 1", len(got))
}
eu := got[0]
if eu.TotalNodes != 5 {
t.Errorf("TotalNodes = %d, want 5", eu.TotalNodes)
}
// usesEu (#eu), usesEurope (#europe), relaysEurope (relays #europe),
// and usesBoth (both) all match one of Europa's two linked scopes;
// usesNeither (#dk) matches neither.
if eu.NodesMatchingArea != 4 {
t.Errorf("NodesMatchingArea = %d, want 4 (any of #eu or #europe should count)", eu.NodesMatchingArea)
}
matchedScopesByKey := map[string][]string{}
for _, m := range eu.Matching {
matchedScopesByKey[m.PublicKey] = m.MatchedScopes
}
if diff := matchedScopesByKey["usesEu"]; len(diff) != 1 || diff[0] != "eu" {
t.Errorf("usesEu.MatchedScopes = %v, want [\"eu\"]", diff)
}
if diff := matchedScopesByKey["usesEurope"]; len(diff) != 1 || diff[0] != "europe" {
t.Errorf("usesEurope.MatchedScopes = %v, want [\"europe\"]", diff)
}
if diff := matchedScopesByKey["relaysEurope"]; len(diff) != 1 || diff[0] != "europe" {
t.Errorf("relaysEurope.MatchedScopes = %v, want [\"europe\"]", diff)
}
if diff := matchedScopesByKey["usesBoth"]; len(diff) != 2 || diff[0] != "eu" || diff[1] != "europe" {
t.Errorf("usesBoth.MatchedScopes = %v, want [\"eu\" \"europe\"] (node uses one scope AND relays the other)", diff)
}
if len(eu.NotMatching) != 1 || eu.NotMatching[0].PublicKey != "usesNeither" {
t.Errorf("NotMatching = %v, want just usesNeither", eu.NotMatching)
}
}