diff --git a/cmd/server/multibyte_region_filter_test.go b/cmd/server/multibyte_region_filter_test.go new file mode 100644 index 00000000..0c90a5cd --- /dev/null +++ b/cmd/server/multibyte_region_filter_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "testing" + "time" +) + +// TestMultiByteCapability_RegionFiltered_PreservesConfirmedStatus verifies +// that GetAnalyticsHashSizes returns a populated multiByteCapability list +// even when a region filter is applied. The frontend (analytics.js) merges +// this into the adopter table to render per-node "confirmed/suspected/unknown" +// badges. When the field is missing or empty under a region filter, every +// row falls back to "unknown" — see meshcore.meshat.se/#/analytics filtered +// by JKG showing 14 "unknown" while the unfiltered view shows 0. +// +// Multi-byte capability is a property of the NODE (advertised hash_size from +// its own adverts), not the observing region. Region filter should affect +// which nodes appear in the result list (multiByteNodes), not their cap status. +// +// Pre-fix behavior: multiByteCapability is only populated when region == "". +// This test fails because result["multiByteCapability"] is absent under +// region="JKG", so the lookup returns nil/false. +func TestMultiByteCapability_RegionFiltered_PreservesConfirmedStatus(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + now := time.Now().UTC() + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + recentEpoch := now.Add(-1 * time.Hour).Unix() + + // Two observers in different regions. + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs-sjc', 'Obs SJC', 'SJC', ?, '2026-01-01T00:00:00Z', 100)`, recent) + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs-jkg', 'Obs JKG', 'JKG', ?, '2026-01-01T00:00:00Z', 100)`, recent) + + // Node A: a JKG-region repeater that advertises multi-byte (hash_size=2). + // Its zero-hop direct advert is only heard by obs-SJC (e.g. an out-of-region + // listener that happens to pick it up). Under the JKG region filter, the + // computeAnalyticsHashSizes() pass will see a smaller advert dataset, but + // the node's multi-byte capability is intrinsic and should still resolve + // to "confirmed" via the global advert evidence. + pkA := "aaa0000000000001" + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) + VALUES (?, 'Node-A', 'repeater')`, pkA) + + decodedA := `{"pubKey":"` + pkA + `","name":"Node-A","type":"ADVERT","flags":{"isRepeater":true}}` + + // Zero-hop direct advert (route_type=2, payload_type=4), + // pathByte 0x40 → hash_size bits 01 → 2 bytes. + // Heard by obs-SJC ONLY. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1240aabbccdd', 'a_zh_direct', ?, 2, 4, ?)`, recent, decodedA) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 12.0, -85, '[]', ?)`, recentEpoch) + + // Node A also appears as a path hop in a JKG-observed packet, so it + // shows up in the JKG region's node list. + // route_type=1 (flood), payload_type=4, pathByte 0x41 (hs=2, hops=1) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1141aabbccdd', 'a_jkg_relay', ?, 1, 4, ?)`, recent, decodedA) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 2, 8.0, -95, '["aa"]', ?)`, recentEpoch) + + store := NewPacketStore(db, nil) + store.Load() + + // Sanity: unfiltered view exposes the field. + unfiltered := store.GetAnalyticsHashSizes("") + if _, ok := unfiltered["multiByteCapability"]; !ok { + t.Fatal("unfiltered result missing multiByteCapability — test setup is wrong") + } + + // The actual assertion: region-filtered view MUST also expose the field + // AND must report Node A as "confirmed", not "unknown". + result := store.GetAnalyticsHashSizes("JKG") + capsRaw, ok := result["multiByteCapability"] + if !ok { + t.Fatalf("expected multiByteCapability in region=JKG result, got keys: %v", keysOf(result)) + } + caps, ok := capsRaw.([]MultiByteCapEntry) + if !ok { + t.Fatalf("expected []MultiByteCapEntry, got %T", capsRaw) + } + + var foundA *MultiByteCapEntry + for i := range caps { + if caps[i].PublicKey == pkA { + foundA = &caps[i] + break + } + } + if foundA == nil { + t.Fatalf("Node A missing from region=JKG multiByteCapability (have %d entries)", len(caps)) + } + if foundA.Status != "confirmed" { + t.Errorf("Node A status under region=JKG = %q, want %q (region filter wrongly downgraded multi-byte capability evidence)", foundA.Status, "confirmed") + } +} + +func keysOf(m map[string]interface{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/cmd/server/store.go b/cmd/server/store.go index 721bfe51..43b48850 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5773,21 +5773,41 @@ func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{ result := s.computeAnalyticsHashSizes(region) - // Add multi-byte capability data (only for unfiltered/global view) + // Multi-byte capability is a NODE property (derived from each node's own + // adverts), not a function of the observing region. The region filter + // should only control which nodes appear in the analytics list, not the + // evidence used to classify their capability. Always compute capability + // against the GLOBAL advert dataset so a region-filtered view doesn't + // downgrade every adopter to "unknown" just because the confirming + // advert was heard by an out-of-region observer (#bug: meshat.se/JKG + // showed 14 unknown vs 0 unknown unfiltered). + globalAdopterHS := make(map[string]int) if region == "" { - // Pass adopter hash sizes so capability can cross-reference - adopterHS := make(map[string]int) if mbNodes, ok := result["multiByteNodes"].([]map[string]interface{}); ok { for _, n := range mbNodes { pk, _ := n["pubkey"].(string) hs, _ := n["hashSize"].(int) if pk != "" && hs >= 2 { - adopterHS[pk] = hs + globalAdopterHS[pk] = hs + } + } + } + } else { + // Pull the global multiByteNodes set without the region filter. + // Use a separate compute call (not the cached path) to avoid + // recursive locking on hashCache and to keep this side-effect free. + globalRes := s.computeAnalyticsHashSizes("") + if mbNodes, ok := globalRes["multiByteNodes"].([]map[string]interface{}); ok { + for _, n := range mbNodes { + pk, _ := n["pubkey"].(string) + hs, _ := n["hashSize"].(int) + if pk != "" && hs >= 2 { + globalAdopterHS[pk] = hs } } } - result["multiByteCapability"] = s.computeMultiByteCapability(adopterHS) } + result["multiByteCapability"] = s.computeMultiByteCapability(globalAdopterHS) s.cacheMu.Lock() s.hashCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}