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