Files
meshcore-analyzer/cmd/server/node_reach_test.go
T
e2212f5015 feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (v2, review-complete) (#1627)
Re-submission of #1625 (which was merged early, then reverted in #1626)
— now with **all three round-1 reviews addressed** so it lands in one
hardened state instead of as post-merge follow-ups.

## What

Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) +
a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which
nodes a node has a **stable two-way RF link** with, derived from raw
`path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B
heard A). A link is bidirectional when both directions have
observations; the **bottleneck** (weaker direction) rates two-way
reliability. Nodes are identified only by **unique 2–3 byte** path
prefixes (1-byte collides → excluded).

## Review fixes folded in vs #1625

**Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc;
`json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row
scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps
(attribution over 100k rows: 102 allocs); `context.Context` plumbed;
cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup;
degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs,
1k/10k/100k, mixed-payload, isolated attribution).

**Correctness/safety + tests (Independent + Kent Beck):** pubkey
validation → 400; error logging instead of silent swallow (first_seen /
degree / marshal→500 / discarded rows); `public_key=?` index use;
canonical `PayloadADVERT`; `min()` builtin; documented cache-slice
immutability; mux ordering comment. New tests: scanReachRows decode,
3-byte token branch, non-advert first-hop guard, observer SNR
aggregation across rows, HTTP-level attribution (asserts non-zero
we_hear/they_hear), 400/404/blacklist/cache-hit.

**UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the
colour+width double-encoding (constant width, colour-only); colour-blind
glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme
`--link-*`; lighter table (horizontal rules, sentence-case headers); map
built once + link layer updated in place on toggle (no flicker);
time-range no longer flashes a loader; `destroy()` generation guard;
statCard escaping; scoped `@media print` to `#nq-report`;
`fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` /
back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS
note; inline styles → CSS; decorative emoji removed.

**Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400.

## Testing
- `cmd/server` full suite green; reach unit + endpoint + bench all pass.
- `eslint public/*.js` (no-undef) and the XSS-sink gate clean.
- E2E updated: request status checks + exact (non-tautological) toggle
assertions + hard map-render assert.

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


---

## TDD-history note (Kent Beck gate)

This branch carries production + tests together, not a fabricated
red→green sequence. That's deliberate: the branch was rebased onto
upstream and the intermediate SHAs were squashed, so reconstructing a
"failing-test-first" commit after the fact would be theatre, not
evidence — and rewriting history to stage it would be dishonest. The
behaviour is instead covered by a comprehensive, anti-tautological suite
(directional attribution edges, 3-byte token branch, non-advert
first-hop guard, observer SNR aggregation, HTTP-level attribution
asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404,
companion mis-attribution, cache eviction). Requesting maintainer
acceptance of the work on test *substance* rather than commit
*choreography*; the net-new-UI exemption is not claimed for the server
endpoint.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-08 22:13:02 -07:00

292 lines
10 KiB
Go

package main
import (
"context"
"database/sql"
"strconv"
"testing"
_ "modernc.org/sqlite"
)
// newReachScanTestDB builds a minimal observer_idx-schema DB with two rows whose
// path contains "01FA" and one that does not, for scanReachRows coverage.
func newReachScanTestDB(t *testing.T) *DB {
t.Helper()
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, from_pubkey TEXT, payload_type INTEGER)`,
`CREATE TABLE observers (id TEXT)`,
`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_idx INTEGER, snr REAL, path_json TEXT, timestamp INTEGER)`,
`INSERT INTO observers (id) VALUES ('OBS1')`, // rowid 1
`INSERT INTO transmissions (id, from_pubkey, payload_type) VALUES (1,'FF00',4),(2,'',5),(3,'',5)`,
`INSERT INTO observations (id, transmission_id, observer_idx, snr, path_json, timestamp) VALUES
(1,1,1,-7.0,'["AA","01FA","BB"]',1000),
(2,2,1,NULL,'["01FA","CC"]',1000),
(3,3,1,-5.0,'["AA","CC"]',1000)`, // no 01FA → excluded
}
for _, s := range stmts {
if _, err := conn.Exec(s); err != nil {
t.Fatal(err)
}
}
return &DB{conn: conn}
}
// resolver that only resolves the exact tokens it's told are unique.
func testResolver(unique map[string]string) func(string) string {
return func(tok string) string {
if pk, ok := unique[tok]; ok {
return pk
}
return "" // ambiguous / unknown → skip
}
}
func TestParsePathTokens(t *testing.T) {
cases := []struct {
in string
want []string
}{
{`["AA","01FA","BB"]`, []string{"AA", "01FA", "BB"}},
{`["aa","01fa"]`, []string{"AA", "01FA"}}, // uppercased
{`["EFEF"]`, []string{"EFEF"}},
{`[]`, nil},
{``, nil},
{`null`, nil},
{`["49A985"]`, []string{"49A985"}}, // 3-byte hop preserved
}
for _, c := range cases {
got := parsePathTokens(c.in)
if len(got) != len(c.want) {
t.Fatalf("parsePathTokens(%q) = %v, want %v", c.in, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("parsePathTokens(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
}
}
}
}
func TestAttributeDirections_PredecessorAndSuccessor(t *testing.T) {
// path A(aa) -> N(01fa) -> B(bb): we hear A, B hears us.
unique := map[string]string{"AA": "aa00", "BB": "bb00"}
rows := []pathRow{{
observerPK: "obs1", payloadType: 5,
path: []string{"AA", "01FA", "BB"},
}}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b", testResolver(unique))
if d.we["aa00"] != 1 {
t.Fatalf("we_hear[aa00]=%d want 1", d.we["aa00"])
}
if d.they["bb00"] != 1 {
t.Fatalf("they_hear[bb00]=%d want 1", d.they["bb00"])
}
if d.relay != 1 {
t.Fatalf("relay=%d want 1", d.relay)
}
}
func TestAttributeDirections_LastHopObserverAndAdvertFirstHop(t *testing.T) {
rows := []pathRow{
// N is last hop → observer heard us directly (+snr).
{observerPK: "obsx", payloadType: 5, path: []string{"AA", "01FA"}, snr: 4.0, snrValid: true},
// N is first hop of an ADVERT (type 4) → we heard the originator.
{observerPK: "obsy", payloadType: 4, fromPubkey: "origin1", path: []string{"01FA", "CC"}},
}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
testResolver(map[string]string{"CC": "cc00"}))
if a, ok := d.obs["obsx"]; !ok || a.count != 1 {
t.Fatalf("observer obsx not counted")
}
if a := d.obs["obsx"]; a.snrN != 1 || a.snrSum != 4.0 {
t.Fatalf("observer snr not aggregated")
}
if d.they["obsx"] != 1 {
t.Fatalf("they_hear[obsx]=%d want 1", d.they["obsx"])
}
if d.we["origin1"] != 1 {
t.Fatalf("we_hear[origin1]=%d want 1 (advert first-hop)", d.we["origin1"])
}
if d.they["cc00"] != 1 {
t.Fatalf("they_hear[cc00]=%d want 1 (successor)", d.they["cc00"])
}
}
func TestAttributeDirections_AmbiguousSkippedAndSelfIgnored(t *testing.T) {
// No observer, so the last-hop observer branch can't fire — this isolates
// the resolve logic. ZZ is unresolved (ambiguous → skipped); the trailing
// 01FA resolves to self (ourPK) and must be ignored as a successor.
rows := []pathRow{{observerPK: "", payloadType: 5, path: []string{"ZZ", "01FA", "01FA"}}}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
testResolver(map[string]string{"01FA": "01fa326b"}))
if len(d.we) != 0 || len(d.they) != 0 {
t.Fatalf("ambiguous/self should yield no edges, got we=%v they=%v", d.we, d.they)
}
}
func TestAttributeDirections_LastHopWithObserverCountsObserver(t *testing.T) {
// Guards the case the previous test deliberately excludes: when our token is
// the last hop AND an observer is present, that observer heard us directly.
rows := []pathRow{{observerPK: "obs1", payloadType: 5, path: []string{"ZZ", "01FA"}}}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
testResolver(map[string]string{}))
if a, ok := d.obs["obs1"]; d.they["obs1"] != 1 || !ok || a.count != 1 {
t.Fatalf("last-hop observer should be counted, got they=%v", d.they)
}
}
func TestReliableTokens(t *testing.T) {
// pm where "01fa" is unique but "01" is shared (collision).
nodes := []nodeInfo{
{PublicKey: "01fa326b0000", Role: "repeater"},
{PublicKey: "0188aaaa0000", Role: "repeater"},
}
pm := buildPrefixMap(nodes)
toks := reliableTokens("01fa326b0000", pm)
if !toks["01FA"] {
t.Fatalf("expected 01FA reliable, got %v", toks)
}
if toks["01"] {
t.Fatalf("1-byte 01 must be excluded (collision), got %v", toks)
}
}
func TestReliableTokens_CompanionNotMisattributed(t *testing.T) {
// pm holds only path-capable relays. A companion target (not in pm) whose
// prefix uniquely matches an UNRELATED relay must yield NO reliable tokens —
// otherwise that relay's traffic would be credited to the companion.
relay := nodeInfo{PublicKey: "aa11000000000000", Role: "repeater"}
pm := buildPrefixMap([]nodeInfo{relay})
companion := "aa11ffff00000000" // shares 2-byte "aa11" with the relay, differs at byte 3
toks := reliableTokens(companion, pm)
if len(toks) != 0 {
t.Fatalf("companion must get no reliable tokens (prefix points at a relay), got %v", toks)
}
// Sanity: the relay itself still resolves to its own prefix.
if !reliableTokens(relay.PublicKey, pm)["AA11"] {
t.Fatalf("relay should keep its own AA11 token")
}
}
func TestScanReachRows_CapTruncates(t *testing.T) {
defer func(orig int) { reachScanRowLimit = orig }(reachScanRowLimit)
reachScanRowLimit = 1 // newReachScanTestDB has 2 matching rows
db := newReachScanTestDB(t)
defer db.conn.Close()
srv := &Server{db: db}
rows := srv.scanReachRows(context.Background(), map[string]bool{"01FA": true}, 0)
if len(rows) != 1 {
t.Fatalf("scan must hard-cap at reachScanRowLimit (1), got %d rows", len(rows))
}
}
func TestReachCacheEviction_BoundedNotWiped(t *testing.T) {
srv := &Server{}
resetReachState(t, srv)
for i := 0; i < reachCacheMax+50; i++ {
srv.reachCachePut("k"+strconv.Itoa(i), []byte("x"))
}
srv.reach.cacheMu.RLock()
n := len(srv.reach.cache)
srv.reach.cacheMu.RUnlock()
// Bounded at the cap and NOT a full wipe (the old crude reset would leave 1).
if n != reachCacheMax {
t.Fatalf("cache size after overflow = %d, want %d (bounded, evict-oldest not full-wipe)", n, reachCacheMax)
}
}
func TestReliableTokens_ThreeByteBranch(t *testing.T) {
// Two nodes share the 2-byte prefix "01fa" but diverge at byte 3, so the
// 3-byte (6-hex) prefix is the shortest unique token. Exercises the l=6
// branch that the 1-/2-byte test does not.
nodes := []nodeInfo{
{PublicKey: "01fa32000000", Role: "repeater"},
{PublicKey: "01fa99000000", Role: "repeater"},
}
pm := buildPrefixMap(nodes)
toks := reliableTokens("01fa32000000", pm)
if toks["01FA"] {
t.Fatalf("2-byte 01FA collides here and must be excluded, got %v", toks)
}
if !toks["01FA32"] {
t.Fatalf("expected 3-byte 01FA32 reliable token, got %v", toks)
}
}
func TestAttributeDirections_NonAdvertFirstHopNotCredited(t *testing.T) {
// Our token is the FIRST hop but payloadType is NOT an advert. The
// fromPubkey must NOT be credited as we_hear (only adverts carry a
// trustworthy originator → first-hop relationship). Guards the
// `payloadType == PayloadADVERT` condition on the first-hop branch.
rows := []pathRow{{
observerPK: "obs1", payloadType: 5, fromPubkey: "origin1",
path: []string{"01FA", "BB"},
}}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
testResolver(map[string]string{"BB": "bb00"}))
if d.we["origin1"] != 0 {
t.Fatalf("non-advert first hop must not credit we_hear[origin1], got %d", d.we["origin1"])
}
if len(d.we) != 0 {
t.Fatalf("expected no we_hear edges, got %v", d.we)
}
if d.they["bb00"] != 1 { // successor still counts
t.Fatalf("they_hear[bb00]=%d want 1", d.they["bb00"])
}
}
func TestAttributeDirections_ObserverAggregatesAcrossRows(t *testing.T) {
// Same observer on the last hop across multiple rows: count and SNR must
// accumulate, not overwrite.
rows := []pathRow{
{observerPK: "obs1", payloadType: 5, path: []string{"AA", "01FA"}, snr: 2.0, snrValid: true},
{observerPK: "obs1", payloadType: 5, path: []string{"BB", "01FA"}, snr: 6.0, snrValid: true},
}
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b", testResolver(nil))
a, ok := d.obs["obs1"]
if !ok || a.count != 2 {
t.Fatalf("observer count should aggregate to 2, got %+v", a)
}
if a.snrN != 2 || a.snrSum != 8.0 {
t.Fatalf("snr should aggregate (n=2,sum=8), got n=%d sum=%v", a.snrN, a.snrSum)
}
if d.they["obs1"] != 2 {
t.Fatalf("they_hear[obs1]=%d want 2", d.they["obs1"])
}
}
func TestScanReachRows_DecodesRows(t *testing.T) {
db := newReachScanTestDB(t)
defer db.conn.Close()
srv := &Server{db: db}
rows := srv.scanReachRows(context.Background(), map[string]bool{"01FA": true}, 0)
if len(rows) != 2 {
t.Fatalf("expected 2 matching rows (non-matching path excluded), got %d", len(rows))
}
// Find the advert row (order is not guaranteed without ORDER BY).
var got *pathRow
for i := range rows {
if rows[i].payloadType == 4 {
got = &rows[i]
}
}
if got == nil {
t.Fatalf("advert row not returned: %+v", rows)
}
// Fields are decoded + normalized: lowercase observer/from, uppercase path.
if got.observerPK != "obs1" || got.fromPubkey != "ff00" {
t.Fatalf("decoded fields wrong: %+v", *got)
}
if len(got.path) != 3 || got.path[1] != "01FA" {
t.Fatalf("path not parsed/uppercased: %v", got.path)
}
if !got.snrValid || got.snr != -7.0 {
t.Fatalf("snr not decoded: valid=%v val=%v", got.snrValid, got.snr)
}
}