mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 13:35:42 +00:00
Sensor nodes embed telemetry (battery_mv, temperature_c) in their advert appdata after the null-terminated name. This commit adds decoding and storage for both the Go ingestor and Node.js backend. Changes: - decoder.go/decoder.js: Parse telemetry bytes from advert appdata (battery_mv as uint16 LE millivolts, temperature_c as int16 LE /100) - db.go/db.js: Add battery_mv INTEGER and temperature_c REAL columns to nodes and inactive_nodes tables, with migration for existing DBs - main.go/server.js: Update node telemetry on advert processing - server db.go: Include battery_mv/temperature_c in node API responses - Tests: Decoder telemetry tests (positive, negative temp, no telemetry), DB migration test, node telemetry update test, server API shape tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1519 lines
44 KiB
Go
1519 lines
44 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"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)
|
|
}
|
|
|
|
// 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
|
|
);
|
|
|
|
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
|
|
);
|
|
|
|
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,
|
|
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
|
|
);
|
|
|
|
CREATE VIEW packets_v AS
|
|
SELECT o.id, t.raw_hex,
|
|
strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp,
|
|
obs.id AS observer_id, obs.name AS observer_name,
|
|
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
|
|
t.payload_type, t.payload_version, o.path_json, t.decoded_json,
|
|
t.created_at
|
|
FROM observations o
|
|
JOIN transmissions t ON t.id = o.transmission_id
|
|
LEFT JOIN observers obs ON obs.rowid = o.observer_idx;
|
|
`
|
|
if _, err := conn.Exec(schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return &DB{conn: conn, isV3: 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)
|
|
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}')`, recent)
|
|
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
|
VALUES ('CCDD', '1234567890abcdef', ?, 1, 5, '{"type":"CHAN","channel":"#test","text":"Hello: World","sender":"TestUser"}')`, 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)
|
|
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}')`, yesterday)
|
|
|
|
// Seed observations (use unix timestamps)
|
|
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
|
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?)`, recentEpoch)
|
|
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
|
VALUES (1, 2, 8.0, -95, '["aa"]', ?)`, 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)
|
|
VALUES (3, 1, 10.0, -92, '["cc"]', ?)`, 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
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{
|
|
InfraDegradedMs: 86400000,
|
|
InfraSilentMs: 259200000,
|
|
NodeDegradedMs: 3600000,
|
|
NodeSilentMs: 86400000,
|
|
}
|
|
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 TestGetObservationsForHash(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
obs, err := db.GetObservationsForHash("abc123def4567890")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(obs) != 2 {
|
|
t.Errorf("expected 2 observations, got %d", len(obs))
|
|
}
|
|
}
|
|
|
|
func TestGetPacketByIDFound(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
pkt, err := db.GetPacketByID(1)
|
|
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 TestGetPacketByIDNotFound(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
pkt, err := db.GetPacketByID(9999)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt != nil {
|
|
t.Error("expected nil for nonexistent packet ID")
|
|
}
|
|
}
|
|
|
|
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 TestGetRecentPacketsForNode(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 20)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(packets) == 0 {
|
|
t.Error("expected packets for TestRepeater")
|
|
}
|
|
}
|
|
|
|
func TestGetRecentPacketsForNodeDefaultLimit(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
packets, err := db.GetRecentPacketsForNode("aabbccdd11223344", "TestRepeater", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if packets == nil {
|
|
t.Error("expected non-nil result")
|
|
}
|
|
}
|
|
|
|
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("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 TestGetNodeHealth(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
t.Run("found", func(t *testing.T) {
|
|
result, err := db.GetNodeHealth("aabbccdd11223344")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected result, got nil")
|
|
}
|
|
node, ok := result["node"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("expected node object")
|
|
}
|
|
if node["name"] != "TestRepeater" {
|
|
t.Errorf("expected TestRepeater, got %v", node["name"])
|
|
}
|
|
stats, ok := result["stats"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("expected stats object")
|
|
}
|
|
if stats["totalPackets"] == nil {
|
|
t.Error("expected totalPackets in stats")
|
|
}
|
|
})
|
|
|
|
t.Run("not found", func(t *testing.T) {
|
|
result, err := db.GetNodeHealth("nonexistent")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != nil {
|
|
t.Error("expected nil for nonexistent node")
|
|
}
|
|
})
|
|
}
|
|
|
|
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 TestGetTimestamps(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
t.Run("with results", func(t *testing.T) {
|
|
ts, err := db.GetTimestamps("2020-01-01")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ts) == 0 {
|
|
t.Error("expected timestamps")
|
|
}
|
|
})
|
|
|
|
t.Run("no results", func(t *testing.T) {
|
|
ts, err := db.GetTimestamps("2099-01-01")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ts) != 0 {
|
|
t.Errorf("expected 0 timestamps, got %d", len(ts))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetObservationCount(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
count := db.GetObservationCount("abc123def4567890")
|
|
if count != 2 {
|
|
t.Errorf("expected 2, got %d", count)
|
|
}
|
|
|
|
count = db.GetObservationCount("nonexistent")
|
|
if count != 0 {
|
|
t.Errorf("expected 0 for nonexistent, got %d", count)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
VALUES ('AA', 'chanmsg00000001', '2026-01-15T10:00:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#general","text":"User1: Hello","sender":"User1"}')`)
|
|
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
|
VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#general","text":"User2: World","sender":"User2"}')`)
|
|
|
|
// 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)
|
|
VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#noname","text":"plain text no colon"}')`)
|
|
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))
|
|
}
|
|
}
|
|
|
|
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{
|
|
InfraDegradedMs: 86400000,
|
|
InfraSilentMs: 259200000,
|
|
NodeDegradedMs: 3600000,
|
|
NodeSilentMs: 86400000,
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestGetNodeHealthNoName(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
|
|
// Insert a node without a name
|
|
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
|
|
db.conn.Exec(`INSERT INTO nodes (public_key, role, last_seen, first_seen, advert_count)
|
|
VALUES ('deadbeef12345678', 'repeater', '2026-01-15T10:00:00Z', '2026-01-01T00:00:00Z', 5)`)
|
|
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
|
VALUES ('DDEE', 'deadbeefhash1234', '2026-01-15T10:05:00Z', 1, 4,
|
|
'{"pubKey":"deadbeef12345678","type":"ADVERT"}')`)
|
|
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
|
VALUES (1, 1, 11.0, -91, '["dd"]', 1736935500)`)
|
|
|
|
result, err := db.GetNodeHealth("deadbeef12345678")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected result, got nil")
|
|
}
|
|
}
|
|
|
|
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)
|
|
VALUES ('AA', 'chanmsg00000004', '2026-01-15T10:00:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#obs","text":"Sender: Test","sender":"Sender"}')`)
|
|
// 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)
|
|
VALUES ('AA', 'chan1hash', '2026-01-15T10:00:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#alpha","text":"Alice: Hello","sender":"Alice"}')`)
|
|
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
|
VALUES ('BB', 'chan2hash', '2026-01-15T10:01:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#beta","text":"Bob: World","sender":"Bob"}')`)
|
|
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 TestGetTracesEmpty(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
seedTestData(t, db)
|
|
|
|
traces, err := db.GetTraces("nonexistenthash1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(traces) != 0 {
|
|
t.Errorf("expected 0 traces, got %d", len(traces))
|
|
}
|
|
}
|
|
|
|
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)
|
|
VALUES ('AA', 'oldhash1', '2026-01-15T10:00:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#test","text":"Alice: Old message","sender":"Alice"}')`)
|
|
// Newer message (first_seen T2 > T1)
|
|
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
|
VALUES ('BB', 'newhash2', '2026-01-15T10:05:00Z', 1, 5,
|
|
'{"type":"CHAN","channel":"#test","text":"Bob: New message","sender":"Bob"}')`)
|
|
|
|
// 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 TestNodeTelemetryFields(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
|
|
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)`)
|
|
|
|
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"])
|
|
}
|
|
|
|
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"])
|
|
}
|
|
|
|
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())
|
|
}
|