mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 03:03:38 +00:00
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.
3444 lines
128 KiB
Go
3444 lines
128 KiB
Go
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)
|
||
}
|
||
}
|