Files
meshcore-analyzer/cmd/server/observer_neighbors_test.go
T
TeTeHackoandClaude Opus 5.5 417c8dd00a feat(observers): latest /neighbors report on the observers list and page (#2270, #2271) (#2300)
Closes #2270, closes #2271.

The ingestor took only the confirmed region scopes out of an observer's
`/neighbors` report (#1865) and dropped the rest. Nothing could
therefore say when an observer last reported, or whom it heard.

## Ingestor

`observer_neighbor_reports` (created in `dbschema.Apply`) keeps the
latest report of each observer, one row per observer, replaced by every
live report.

The report is unauthenticated, so `neighbors[]` is stored sanitised:

- only 64-hex keys
- `snr` and `heard_secs_ago` finite and in range
- `status` limited to `[a-z_]`
- `scopes` clamped
- at most 500 rows

`total_neighbors` is believed only when it is not below the number of
rows that arrived.

A **retained replay** only fills a missing row, and it is dated by the
report's own `timestamp`. After an ingestor restart, a long-dead
observer therefore does not look as if it just reported.

## API

The table is optional for the server (no `AssertReady` change); without
it the fields are simply absent.

- `GET /api/observers`: `neighbors_report_at` and `neighbors_count`,
from one batch query in the existing cached builder.
- `GET /api/observers/{id}`: also `neighbor_report`, which is
`{received_at, reported_at, neighbor_count, queried_neighbors,
self_scopes, self_default_scope, neighbors: [{pubkey, name, role, snr,
heard_secs_ago, status, scopes}]}`. Names and roles come from the node
table in one query.

## Frontend

`public/observer-neighbors.js` is shared by both pages and escapes
everything.

- **#2270, observers list:** a sortable **Neighbors** column, e.g. `3h
ago · 12`, or `—` when the observer never reported. The same value
appears in the slide-over.
- **#2270, observer page:** **Neighbors Report** and **Neighbors** stat
cards.
- **#2271, observer page:** a **Neighbors** table listing each neighbor
by name (a link to the node) or key prefix, with SNR, heard, scope query
status and scopes. The heading says when the list is shorter than the
count, e.g. `(2 of 3 listed)`.

## Tests

- **ingestor:** sanitising, the cap, live replace, the retained rules
(fill only, dated by the report, undated ignored) and the MQTT topic end
to end. The existing #1865 scope tests are unchanged and still pass.
- **server:** the list summary, the detail with names, and both
endpoints without the table.
- **frontend:**
  - unit tests of the helpers
- an XSS case for every rendered field; I checked that it fails when
escaping is removed
- `test-observers-headings` now expects the new column after `Last
Packet`
- **E2E:** `test-observer-neighbors-2270-e2e.js` intercepts the observer
endpoints, since the fixture has no reports. It is listed in
`scripts/non-unit-tests.json` and on shard 2.

Gates that pass:

- `go test -race` in `cmd/server`, `cmd/ingestor` and
`internal/dbschema`
- `sh test-all.sh`
- `eslint@8`, with 0 errors
- the XSS diff gate
- these E2E suites: the new one,
`test-issue-1639-observers-sort-e2e.js`,
`test-observer-iata-1188-e2e.js` and `test-e2e-playwright.js`

Performance: the list adds one primary-key-sized query per cache fill,
and the detail adds two small queries per request.

Also fixes #2330 (folded in from #2337, second commit).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:36:53 +01:00

105 lines
3.7 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// #2270 / #2271: the latest /neighbors report on the observer endpoints.
func seedNeighborReport(t *testing.T, srv *Server) {
t.Helper()
for _, s := range []string{
`CREATE TABLE IF NOT EXISTS observer_neighbor_reports (observer_id TEXT PRIMARY KEY, received_at TEXT NOT NULL,
reported_at TEXT, neighbor_count INTEGER NOT NULL, queried_neighbors INTEGER, self_scopes TEXT,
self_default_scope TEXT, neighbors_json TEXT NOT NULL)`,
// aabbccdd11223344 is the fixture's TestRepeater; the second key is unknown.
`INSERT INTO observer_neighbor_reports VALUES ('obs1', '2026-10-25T10:00:00Z', '2026-10-25T09:59:58Z', 2, 2,
'#tst', '*', '[{"pubkey":"aabbccdd11223344","snr":6.5,"heard_secs_ago":30,"status":"responded","scopes":"#tst"},
{"pubkey":"ff00ff00ff00ff00","status":"timeout"}]')`,
} {
if _, err := srv.db.conn.Exec(s); err != nil {
t.Fatal(err)
}
}
}
func getObservers(t *testing.T, srv *Server, router http.Handler, url string, into interface{}) {
t.Helper()
srv.observersCacheV2.ptr.Store(nil) // the list is cached for 30 s
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("%s: %d %s", url, w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), into); err != nil {
t.Fatal(err)
}
}
func TestObserversListNeighborSummary(t *testing.T) {
srv, router := setupTestServer(t)
seedNeighborReport(t, srv)
var list ObserverListResponse
getObservers(t, srv, router, "/api/observers", &list)
got := map[string]ObserverResp{}
for _, o := range list.Observers {
got[o.ID] = o
}
o1 := got["obs1"]
if o1.NeighborsReportAt == nil || *o1.NeighborsReportAt != "2026-10-25T10:00:00Z" || o1.NeighborsCount == nil || *o1.NeighborsCount != 2 {
t.Errorf("obs1 = at %v count %v", o1.NeighborsReportAt, o1.NeighborsCount)
}
if o1.NeighborReport != nil {
t.Error("the list must not carry the full report")
}
if o2 := got["obs2"]; o2.NeighborsReportAt != nil || o2.NeighborsCount != nil {
t.Errorf("obs2 never reported but has %v / %v", o2.NeighborsReportAt, o2.NeighborsCount)
}
}
func TestObserverDetailNeighborReport(t *testing.T) {
srv, router := setupTestServer(t)
seedNeighborReport(t, srv)
var detail ObserverResp
getObservers(t, srv, router, "/api/observers/obs1", &detail)
rep := detail.NeighborReport
if rep == nil || rep.NeighborCount != 2 || len(rep.Neighbors) != 2 || rep.SelfScopes == nil || *rep.SelfScopes != "#tst" {
t.Fatalf("report = %+v", rep)
}
known, unknown := rep.Neighbors[0], rep.Neighbors[1]
if known.Name == nil || *known.Name != "TestRepeater" || known.Role == nil || *known.Role != "repeater" ||
known.SNR == nil || *known.SNR != 6.5 || known.Status != "responded" {
t.Errorf("known neighbor = %+v", known)
}
if unknown.Name != nil || unknown.Status != "timeout" {
t.Errorf("unknown neighbor = %+v", unknown)
}
var none ObserverResp
getObservers(t, srv, router, "/api/observers/obs2", &none)
if none.NeighborReport != nil || none.NeighborsReportAt != nil {
t.Errorf("obs2 detail has a report: %+v", none.NeighborReport)
}
}
// Without the table (a server ahead of its ingestor) the endpoints still
// answer, just without neighbor data.
func TestObserversWithoutNeighborTable(t *testing.T) {
srv, router := setupTestServer(t)
var list ObserverListResponse
getObservers(t, srv, router, "/api/observers", &list)
for _, o := range list.Observers {
if o.NeighborsReportAt != nil {
t.Errorf("%v has a report without the table", o.ID)
}
}
var detail ObserverResp
getObservers(t, srv, router, "/api/observers/obs1", &detail)
if detail.NeighborReport != nil {
t.Error("detail has a report without the table")
}
}