Files
meshcore-analyzer/cmd/server/issue804_repeater_region_test.go
T
efiten 317b59ab10 feat: area-based visual node filter — attribute packets by transmitter GPS (#804) (#839)
## Summary

- Adds configurable GPS polygon areas to `config.json`; nodes are
attributed to an area if their last-known position falls inside the
polygon
- New `Area: …` dropdown filter (matching the existing region filter
style) appears on all analytics, nodes, packets, map, and live screens
when areas are configured
- Backend resolves area membership with a 30s TTL cache; area filter
bypasses the 500-node cap on `/api/bulk-health` so all area nodes are
always returned
- Includes a polygon builder tool (`/area-map.html`) for drawing and
exporting area boundaries

## Changes

**Backend**
- `AreaEntry` type + `Areas` config field
- `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL,
`areaNodeMu` RWMutex)
- `PacketQuery.Area` + `filterPackets` polygon check
- `?area=` param propagated through all analytics, topology,
clock-health, and bulk-health routes
- `/api/config/areas` endpoint

**Frontend**
- `area-filter.js`: single-select dropdown, persists to localStorage,
cleans up stale keys on load
- Wired into analytics, nodes, packets, channels, map, and live pages
- Live map clears node markers on area change

**Docs & tools**
- `docs/user-guide/area-filter.md` — configuration and usage guide
- `docs/api-spec.md` — updated with new endpoint and `?area=` param
table
- `tools/area-map.html` — polygon builder for defining area boundaries
- Demo areas added to `config.example.json`

## Test plan

- [x] No areas configured → filter dropdown does not appear on any page
- [x] Areas configured → dropdown appears, "All" selected by default
- [x] Selecting an area filters nodes/packets/topology/map correctly
- [x] Selecting "All" restores unfiltered view
- [x] Selection persists across page reloads (localStorage)
- [x] Stale localStorage key (area removed from config) is cleared on
load
- [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node
cap)
- [x] `/api/config/areas` returns correct list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-21 14:00:15 -07:00

148 lines
5.9 KiB
Go

package main
import (
"testing"
"time"
)
// TestIssue804_AnalyticsAttributesByRepeaterRegion verifies that analytics
// (specifically GetAnalyticsHashSizes) attribute multi-byte nodes to the
// REPEATER's home region, not the observer that happened to hear the relay.
//
// Scenario from #804:
// - PDX-Repeater is a multi-byte (hashSize=2) repeater whose ZERO-HOP direct
// adverts are only heard by obs-PDX (a PDX observer). That zero-hop direct
// advert is the most reliable home-region signal — it cannot have been
// relayed.
// - A flood advert from PDX-Repeater (hashSize=2) propagates and is heard by
// obs-SJC (a SJC observer) via a multi-hop relay path.
// - When the user asks for region=SJC analytics, the PDX-Repeater MUST NOT
// pollute SJC's multiByteNodes — it lives in PDX.
// - The result should also expose attributionMethod="repeater" so the API
// consumer knows which method was used.
//
// Pre-fix behavior: PDX-Repeater appears in SJC's multiByteNodes because the
// filter is observer-based. This test fails on the pre-fix code at the
// "want PDX-Repeater EXCLUDED" assertion.
func TestIssue804_AnalyticsAttributesByRepeaterRegion(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()
// Observers: one in PDX, one in SJC
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs-pdx', 'Obs PDX', 'PDX', ?, '2026-01-01T00:00:00Z', 100)`, recent)
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)
// PDX-Repeater node (lives in Portland)
pdxPK := "pdx0000000000001"
db.conn.Exec(`INSERT INTO nodes (public_key, name, role)
VALUES (?, 'PDX-Repeater', 'repeater')`, pdxPK)
// SJC-Repeater node (lives in San Jose) — sanity baseline
sjcPK := "sjc0000000000001"
db.conn.Exec(`INSERT INTO nodes (public_key, name, role)
VALUES (?, 'SJC-Repeater', 'repeater')`, sjcPK)
pdxDecoded := `{"pubKey":"` + pdxPK + `","name":"PDX-Repeater","type":"ADVERT","flags":{"isRepeater":true}}`
sjcDecoded := `{"pubKey":"` + sjcPK + `","name":"SJC-Repeater","type":"ADVERT","flags":{"isRepeater":true}}`
// 1) PDX-Repeater zero-hop DIRECT advert heard only by obs-PDX.
// Establishes PDX as the repeater's home region.
// raw_hex header 0x12 = route_type 2 (direct), payload_type 4
// pathByte 0x40 (hashSize bits=01 → 2, hop_count=0)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1240aabbccdd', 'pdx_zh_direct', ?, 2, 4, ?)`, recent, pdxDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 12.0, -85, '[]', ?)`, recentEpoch)
// 2) PDX-Repeater FLOOD advert with hashSize=2 (reliable).
// Heard ONLY by obs-SJC via a relay path (this is the polluting case).
// raw_hex header 0x11 = route_type 1 (flood), payload_type 4
// pathByte 0x41 (hashSize bits=01 → 2, hop_count=1)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1141aabbccdd', 'pdx_flood', ?, 1, 4, ?)`, recent, pdxDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 2, 8.0, -95, '["aa11"]', ?)`, recentEpoch)
// 3) SJC-Repeater zero-hop DIRECT advert heard only by obs-SJC.
// Establishes SJC as the repeater's home region.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1240ccddeeff', 'sjc_zh_direct', ?, 2, 4, ?)`, recent, sjcDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 2, 14.0, -82, '[]', ?)`, recentEpoch)
// 4) SJC-Repeater FLOOD advert with hashSize=2, heard by obs-SJC.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1141ccddeeff', 'sjc_flood', ?, 1, 4, ?)`, recent, sjcDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (4, 2, 11.0, -88, '["cc22"]', ?)`, recentEpoch)
store := NewPacketStore(db, nil)
store.Load()
t.Run("region=SJC excludes PDX-Repeater (heard but not home)", func(t *testing.T) {
result := store.GetAnalyticsHashSizes("SJC", "")
mb, ok := result["multiByteNodes"].([]map[string]interface{})
if !ok {
t.Fatal("expected multiByteNodes slice")
}
var foundPDX, foundSJC bool
for _, n := range mb {
pk, _ := n["pubkey"].(string)
if pk == pdxPK {
foundPDX = true
}
if pk == sjcPK {
foundSJC = true
}
}
if foundPDX {
t.Errorf("PDX-Repeater leaked into SJC analytics — region attribution still observer-based (#804 not fixed)")
}
if !foundSJC {
t.Errorf("SJC-Repeater missing from SJC analytics — fix over-filtered")
}
})
t.Run("API exposes attributionMethod", func(t *testing.T) {
result := store.GetAnalyticsHashSizes("SJC", "")
method, ok := result["attributionMethod"].(string)
if !ok {
t.Fatal("expected attributionMethod string field on result")
}
if method != "repeater" {
t.Errorf("attributionMethod = %q, want %q", method, "repeater")
}
})
t.Run("region=PDX excludes SJC-Repeater", func(t *testing.T) {
result := store.GetAnalyticsHashSizes("PDX", "")
mb, _ := result["multiByteNodes"].([]map[string]interface{})
var foundPDX, foundSJC bool
for _, n := range mb {
pk, _ := n["pubkey"].(string)
if pk == pdxPK {
foundPDX = true
}
if pk == sjcPK {
foundSJC = true
}
}
if !foundPDX {
t.Errorf("PDX-Repeater missing from PDX analytics")
}
if foundSJC {
t.Errorf("SJC-Repeater leaked into PDX analytics")
}
})
}