diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 99157e65..9c5e3490 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -2066,6 +2066,19 @@ func TestStoreGetAnalyticsTopology(t *testing.T) { t.Error("expected non-nil result") } + // #155: uniqueNodes must match DB 7-day active count, not hop resolution + stats, err := db.GetStats() + if err != nil { + t.Fatalf("GetStats failed: %v", err) + } + un, ok := result["uniqueNodes"].(int) + if !ok { + t.Fatalf("uniqueNodes is not int: %T", result["uniqueNodes"]) + } + if un != stats.TotalNodes { + t.Errorf("uniqueNodes=%d should match stats totalNodes=%d", un, stats.TotalNodes) + } + t.Run("with region", func(t *testing.T) { r := store.GetAnalyticsTopology("SJC") _ = r @@ -2095,6 +2108,64 @@ func TestStoreGetAnalyticsChannels(t *testing.T) { }) } +// Regression test for #154: channelHash is a number in decoded JSON from decoder.js, +// not a string. The Go struct must handle both types correctly. +func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339) + recentEpoch := time.Now().Add(-1 * time.Hour).Unix() + + // Insert GRP_TXT packets with numeric channelHash (matches decoder.js output) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('DD01', 'grp_num_hash_1', ?, 1, 5, '{"type":"GRP_TXT","channelHash":97,"channelHashHex":"61","decryptionStatus":"no_key"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('DD02', 'grp_num_hash_2', ?, 1, 5, '{"type":"GRP_TXT","channelHash":42,"channelHashHex":"2A","decryptionStatus":"no_key"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (4, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + // Also a decrypted CHAN with numeric channelHash + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('DD03', 'chan_num_hash_3', ?, 1, 5, '{"type":"CHAN","channel":"general","channelHash":97,"channelHashHex":"61","text":"hello","sender":"Alice"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (5, 1, 12.0, -88, '[]', ?)`, recentEpoch) + + store := NewPacketStore(db) + store.Load() + result := store.GetAnalyticsChannels("") + + channels := result["channels"].([]map[string]interface{}) + if len(channels) < 2 { + t.Errorf("expected at least 2 channels (hash 97 + hash 42), got %d", len(channels)) + } + + // Verify no channel has hash "?" (would mean parsing failed) + for _, ch := range channels { + if ch["hash"] == "?" { + t.Errorf("channel has hash '?' — numeric channelHash was not parsed: %v", ch) + } + } + + // Verify the decrypted CHAN channel has the correct name + foundGeneral := false + for _, ch := range channels { + if ch["name"] == "general" { + foundGeneral = true + if ch["hash"] != "97" { + t.Errorf("expected hash '97' for general channel, got %v", ch["hash"]) + } + } + } + if !foundGeneral { + t.Error("expected to find channel named 'general'") + } +} + func TestStoreGetAnalyticsDistance(t *testing.T) { db := setupRichTestDB(t) defer db.Close() diff --git a/cmd/server/store.go b/cmd/server/store.go index c62f922f..649ba894 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -1340,12 +1340,27 @@ func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{} } type decodedGrp struct { - Type string `json:"type"` - Channel string `json:"channel"` - ChannelHash string `json:"channelHash"` - ChannelHash2 string `json:"channel_hash"` - Text string `json:"text"` - Sender string `json:"sender"` + Type string `json:"type"` + Channel string `json:"channel"` + ChannelHash interface{} `json:"channelHash"` + ChannelHash2 string `json:"channel_hash"` + Text string `json:"text"` + Sender string `json:"sender"` + } + + // Convert channelHash (number or string in JSON) to string + chHashStr := func(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case float64: + return strconv.FormatFloat(val, 'f', -1, 64) + default: + return fmt.Sprintf("%v", val) + } } type chanInfo struct { @@ -1382,7 +1397,7 @@ func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{} continue } - hash := decoded.ChannelHash + hash := chHashStr(decoded.ChannelHash) if hash == "" { hash = decoded.ChannelHash2 } @@ -1391,14 +1406,11 @@ func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{} } name := decoded.Channel if name == "" { - if decoded.Type == "CHAN" { - name = "ch" + hash - } else { - name = "ch" + hash - } + name = "ch" + hash } encrypted := decoded.Text == "" && decoded.Sender == "" - chKey := name + // Use hash as key for grouping (matches Node.js String(hash)) + chKey := hash if decoded.Type == "CHAN" && decoded.Channel != "" { chKey = hash + "_" + decoded.Channel } @@ -2427,14 +2439,13 @@ func (s *PacketStore) GetAnalyticsTopology(region string) map[string]interface{} bestPathList = bestPathList[:50] } - // Count only hops that resolve to real nodes (not unresolved 1-byte prefixes) - resolvedSet := map[string]bool{} - for hop := range hopFreq { - if r := resolveHop(hop); r != nil { - resolvedSet[r.PublicKey] = true + // Use DB 7-day active node count (matches /api/stats totalNodes) + uniqueNodes := 0 + if s.db != nil { + if stats, err := s.db.GetStats(); err == nil { + uniqueNodes = stats.TotalNodes } } - uniqueNodes := len(resolvedSet) return map[string]interface{}{ "uniqueNodes": uniqueNodes, diff --git a/server.js b/server.js index a13ae3be..7d7e70ae 100644 --- a/server.js +++ b/server.js @@ -1605,7 +1605,7 @@ app.get('/api/analytics/topology', (req, res) => { .slice(0, 50); const _topoResult = { - uniqueNodes: new Set(Object.keys(hopFreq)).size, + uniqueNodes: db.getStats().totalNodes, avgHops, medianHops, maxHops, hopDistribution, topRepeaters, topPairs, hopsVsSnr, observers: observers.map(o => ({ id: o.observer_id, name: o.observer_name || o.observer_id })), diff --git a/test-server-routes.js b/test-server-routes.js index 9326c67d..9dc08996 100644 --- a/test-server-routes.js +++ b/test-server-routes.js @@ -514,6 +514,13 @@ seedTestData(); assert(typeof r.body === 'object', 'should return topology'); }); + await t('GET /api/analytics/topology uniqueNodes matches stats totalNodes (#155)', async () => { + const topo = await request(app).get('/api/analytics/topology').expect(200); + const stats = await request(app).get('/api/stats').expect(200); + assert(topo.body.uniqueNodes === stats.body.totalNodes, + `uniqueNodes (${topo.body.uniqueNodes}) should match stats totalNodes (${stats.body.totalNodes})`); + }); + await t('GET /api/analytics/topology with region', async () => { await request(app).get('/api/analytics/topology?region=SFO').expect(200); });