Compare commits

...

1 Commits

Author SHA1 Message Date
you
1a714731f0 fix(#871): Recent Packets API — always return hash + timestamp (root cause of #857)
Root cause: enrichObs() looked up the parent transmission via byTxID,
but if the tx had been evicted from memory, the lookup returned nil and
hash/payload_type/route_type/decoded_json were omitted from the response.

Fix:
- enrichObs: fall back to DB lookup (GetTransmissionByID) when the
  in-memory tx is missing, ensuring hash and all tx fields are always
  present in the response
- GetNodeHealth recentPackets: defensive filter skips entries with nil
  hash or timestamp (belt-and-suspenders)
- Observer analytics recentPackets: skip entries missing hash
- Nil-safe: no panic when store has no DB reference

Tests: 4 new tests covering the DB fallback, nil-DB edge case, and
GetNodeHealth field invariants.

Closes #871
Refs #857
2026-04-21 18:00:57 +00:00
3 changed files with 224 additions and 1 deletions

194
cmd/server/issue871_test.go Normal file
View File

@@ -0,0 +1,194 @@
package main
import (
"database/sql"
"testing"
"time"
"path/filepath"
_ "modernc.org/sqlite"
)
// setupTestDB871 creates a test DB with schema and returns a read-only *DB handle.
func setupTestDB871(t *testing.T) (*DB, *sql.DB) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test871.db")
// Open writable connection for setup
rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
_, err = rw.Exec(`
CREATE TABLE IF NOT EXISTS nodes (
public_key TEXT PRIMARY KEY,
name TEXT, role TEXT,
lat REAL, lon REAL,
last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS observers (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
name TEXT
);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL,
observer_id TEXT,
observer_name TEXT,
direction TEXT,
snr REAL, rssi REAL, score INTEGER,
path_json TEXT, timestamp TEXT
);
`)
if err != nil {
t.Fatal(err)
}
// Open read-only handle for the store
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
rw.Close()
})
return db, rw
}
// TestEnrichObsFallbackToDB verifies that enrichObs falls back to the DB when
// the parent transmission has been evicted from memory (#871 root cause).
func TestEnrichObsFallbackToDB(t *testing.T) {
db, rw := setupTestDB871(t)
now := time.Now().UTC().Format(time.RFC3339)
_, err := rw.Exec(
`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type, decoded_json) VALUES (?, ?, ?, ?, ?)`,
"aabbcc", "abc123", now, 4, `{"pubKey":"pk1"}`,
)
if err != nil {
t.Fatal(err)
}
store := NewPacketStore(db, &PacketStoreConfig{})
// Observation references tx_id=1, but tx is NOT in byTxID (simulates eviction)
obs := &StoreObs{
ID: 1,
TransmissionID: 1,
ObserverID: "obs1",
ObserverName: "Observer1",
Timestamp: now,
}
result := store.enrichObs(obs)
// hash must be present from DB fallback
if result["hash"] == nil {
t.Errorf("enrichObs: hash is nil — DB fallback failed")
}
if h, ok := result["hash"].(string); !ok || h != "abc123" {
t.Errorf("enrichObs: expected hash 'abc123', got %v", result["hash"])
}
if result["payload_type"] == nil {
t.Errorf("enrichObs: payload_type is nil — DB fallback failed")
}
// When tx IS in memory, it should use the in-memory path
pt := 4
store.byTxID[1] = &StoreTx{
ID: 1, Hash: "abc123", FirstSeen: now,
PayloadType: &pt, RawHex: "aabbcc",
}
result2 := store.enrichObs(obs)
if result2["hash"] == nil {
t.Errorf("enrichObs with in-memory tx: hash is nil")
}
}
// TestGetNodeHealthRecentPacketsNoNilFields verifies that GetNodeHealth's
// recentPackets never contains entries with nil hash or timestamp.
func TestGetNodeHealthRecentPacketsNoNilFields(t *testing.T) {
db, rw := setupTestDB871(t)
now := time.Now().UTC().Format(time.RFC3339)
_, err := rw.Exec(
`INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)`,
"pk1", "TestNode", "repeater", now,
)
if err != nil {
t.Fatal(err)
}
store := NewPacketStore(db, &PacketStoreConfig{})
pt := 4
tx := &StoreTx{
ID: 1, Hash: "hash1", FirstSeen: now,
PayloadType: &pt, DecodedJSON: `{"pubKey":"pk1"}`,
obsKeys: make(map[string]bool), observerSet: make(map[string]bool),
}
store.byTxID[1] = tx
store.byHash["hash1"] = tx
store.byNode["pk1"] = []*StoreTx{tx}
store.nodeHashes["pk1"] = map[string]bool{"hash1": true}
result, err := store.GetNodeHealth("pk1")
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("GetNodeHealth returned nil")
}
packets, ok := result["recentPackets"].([]map[string]interface{})
if !ok {
t.Fatal("recentPackets is not []map[string]interface{}")
}
for i, p := range packets {
if p["hash"] == nil {
t.Errorf("recentPackets[%d] has nil hash", i)
}
if p["timestamp"] == nil {
t.Errorf("recentPackets[%d] has nil timestamp", i)
}
}
}
// TestEnrichObsNilDB verifies enrichObs doesn't panic when db is nil.
func TestEnrichObsNilDB(t *testing.T) {
store := &PacketStore{
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
}
obs := &StoreObs{
ID: 1, TransmissionID: 999,
Timestamp: "2026-01-01T00:00:00Z",
}
// Should not panic
result := store.enrichObs(obs)
if result["hash"] != nil {
t.Errorf("expected nil hash when no DB and no in-memory tx, got %v", result["hash"])
}
}

View File

@@ -2083,7 +2083,7 @@ func (s *Server) handleObserverAnalytics(w http.ResponseWriter, r *http.Request)
}
snrBuckets[bucket].Count++
}
if i < 20 {
if i < 20 && enriched["hash"] != nil {
recentPackets = append(recentPackets, enriched)
}
}

View File

@@ -2412,6 +2412,31 @@ func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
m["payload_type"] = intPtrOrNil(tx.PayloadType)
m["route_type"] = intPtrOrNil(tx.RouteType)
m["decoded_json"] = strOrNil(tx.DecodedJSON)
} else {
// Parent tx was evicted from memory — fall back to DB lookup so that
// hash/timestamp are always present in the response (root cause of #857).
if s.db != nil {
if row, err := s.db.GetTransmissionByID(obs.TransmissionID); err == nil && row != nil {
if h, ok := row["hash"]; ok {
m["hash"] = h
}
if ts, ok := row["first_seen"]; ok && m["timestamp"] == nil {
m["timestamp"] = ts
}
if rh, ok := row["raw_hex"]; ok {
m["raw_hex"] = rh
}
if pt, ok := row["payload_type"]; ok {
m["payload_type"] = pt
}
if rt, ok := row["route_type"]; ok {
m["route_type"] = rt
}
if dj, ok := row["decoded_json"]; ok {
m["decoded_json"] = dj
}
}
}
}
return m
@@ -6580,6 +6605,10 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro
for i := len(packets) - 1; i >= len(packets)-recentLimit; i-- {
p := s.txToMapWithRP(packets[i])
delete(p, "observations")
// Defensive: skip packets missing hash or timestamp (belt-and-suspenders for #857)
if p["hash"] == nil || p["timestamp"] == nil {
continue
}
recentPackets = append(recentPackets, p)
}