mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-31 18:05:45 +00:00
SQLite :memory: databases create separate databases per connection. When the connection pool opens multiple connections (e.g. poller goroutine vs main test goroutine), tables created on one connection are invisible to others. Setting MaxOpenConns(1) ensures all queries use the same in-memory database, fixing TestPollerBroadcastsMultipleObservations.
1323 lines
39 KiB
Go
1323 lines
39 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)
|
|
}
|
|
// 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
|
|
);
|
|
|
|
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
|
|
);
|
|
|
|
`
|
|
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 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("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 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 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 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()
|
|
|
|
// 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())
|
|
}
|