mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 00:33:38 +00:00
fix: wire last_neighbors_report_at through the ObserverResp API DTO
The Observer DB struct carried the field correctly, but handleObservers/ handleObserverDetail build a separate field-by-field ObserverResp DTO for the actual JSON response, which never picked it up -- caught live on stg (field missing from /api/observers entirely). Added handler-level tests that decode the JSON response, since the existing DB-layer test for this class of field didn't reach the DTO conversion at all.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
// #1865 follow-up (cwichura, PR #1867): /api/observers and
|
||||
// /api/observers/{id} must expose last_neighbors_report_at end-to-end.
|
||||
// This is a handler-level test on purpose: the DB layer (db.GetObservers/
|
||||
// GetObserverByID) can carry the field correctly while the HTTP handler's
|
||||
// separate ObserverResp DTO silently drops it during the field-by-field
|
||||
// conversion in routes.go -- exactly the gap that shipped once already.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleObservers_ExposesLastNeighborsReportAt(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
ts := "2026-07-26T10:00:00Z"
|
||||
if _, err := srv.db.conn.Exec(`UPDATE observers SET last_neighbors_report_at = ? WHERE id = ?`, ts, "obs1"); err != nil {
|
||||
t.Fatalf("seed last_neighbors_report_at: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Observers []map[string]interface{} `json:"observers"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
for _, o := range body.Observers {
|
||||
if o["id"] == "obs1" {
|
||||
got = o
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("obs1 not found in /api/observers response")
|
||||
}
|
||||
if v, ok := got["last_neighbors_report_at"]; !ok || v != ts {
|
||||
t.Errorf("last_neighbors_report_at = %v (present=%v), want %q", v, ok, ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObservers_NilLastNeighborsReportAtWhenNeverReported(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
_ = srv
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Observers []map[string]interface{} `json:"observers"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(body.Observers) == 0 {
|
||||
t.Fatal("expected at least one seeded observer")
|
||||
}
|
||||
// seedTestData never sets last_neighbors_report_at -- key must be
|
||||
// present (not omitted) and null, so the frontend can distinguish
|
||||
// "field exists, never reported" from a stale/misdeployed backend.
|
||||
got := body.Observers[0]
|
||||
v, ok := got["last_neighbors_report_at"]
|
||||
if !ok {
|
||||
t.Fatal("last_neighbors_report_at key missing from response entirely")
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("last_neighbors_report_at = %v, want nil for an observer that never reported", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObserverDetail_ExposesLastNeighborsReportAt(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
ts := "2026-07-26T11:00:00Z"
|
||||
if _, err := srv.db.conn.Exec(`UPDATE observers SET last_neighbors_report_at = ? WHERE id = ?`, ts, "obs1"); err != nil {
|
||||
t.Fatalf("seed last_neighbors_report_at: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if v, ok := got["last_neighbors_report_at"]; !ok || v != ts {
|
||||
t.Errorf("last_neighbors_report_at = %v (present=%v), want %q", v, ok, ts)
|
||||
}
|
||||
}
|
||||
+10
-8
@@ -3167,10 +3167,11 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) {
|
||||
Model: o.Model, Firmware: o.Firmware,
|
||||
ClientVersion: o.ClientVersion, Radio: o.Radio,
|
||||
BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs,
|
||||
NoiseFloor: o.NoiseFloor,
|
||||
LastPacketAt: o.LastPacketAt,
|
||||
PacketsLastHour: plh,
|
||||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||||
NoiseFloor: o.NoiseFloor,
|
||||
LastPacketAt: o.LastPacketAt,
|
||||
LastNeighborsReportAt: o.LastNeighborsReportAt,
|
||||
PacketsLastHour: plh,
|
||||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||||
CanRelay: o.CanRelay,
|
||||
}
|
||||
applyObserverNaiveClock(&resp, o, nowTime)
|
||||
@@ -3213,10 +3214,11 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
|
||||
Model: obs.Model, Firmware: obs.Firmware,
|
||||
ClientVersion: obs.ClientVersion, Radio: obs.Radio,
|
||||
BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs,
|
||||
NoiseFloor: obs.NoiseFloor,
|
||||
LastPacketAt: obs.LastPacketAt,
|
||||
PacketsLastHour: plh,
|
||||
CanRelay: obs.CanRelay,
|
||||
NoiseFloor: obs.NoiseFloor,
|
||||
LastPacketAt: obs.LastPacketAt,
|
||||
LastNeighborsReportAt: obs.LastNeighborsReportAt,
|
||||
PacketsLastHour: plh,
|
||||
CanRelay: obs.CanRelay,
|
||||
}
|
||||
applyObserverNaiveClock(&resp, obs, time.Now().UTC())
|
||||
return resp
|
||||
|
||||
+22
-18
@@ -1290,24 +1290,28 @@ type ChannelMessagesResponse struct {
|
||||
// ─── Observers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ObserverResp struct {
|
||||
ID string `json:"id"`
|
||||
Name interface{} `json:"name"`
|
||||
IATA interface{} `json:"iata"`
|
||||
LastSeen interface{} `json:"last_seen"`
|
||||
FirstSeen interface{} `json:"first_seen"`
|
||||
PacketCount int `json:"packet_count"`
|
||||
Model interface{} `json:"model"`
|
||||
Firmware interface{} `json:"firmware"`
|
||||
ClientVersion interface{} `json:"client_version"`
|
||||
Radio interface{} `json:"radio"`
|
||||
BatteryMv interface{} `json:"battery_mv"`
|
||||
UptimeSecs interface{} `json:"uptime_secs"`
|
||||
NoiseFloor interface{} `json:"noise_floor"`
|
||||
LastPacketAt interface{} `json:"last_packet_at"`
|
||||
PacketsLastHour int `json:"packetsLastHour"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
NodeRole interface{} `json:"nodeRole"`
|
||||
ID string `json:"id"`
|
||||
Name interface{} `json:"name"`
|
||||
IATA interface{} `json:"iata"`
|
||||
LastSeen interface{} `json:"last_seen"`
|
||||
FirstSeen interface{} `json:"first_seen"`
|
||||
PacketCount int `json:"packet_count"`
|
||||
Model interface{} `json:"model"`
|
||||
Firmware interface{} `json:"firmware"`
|
||||
ClientVersion interface{} `json:"client_version"`
|
||||
Radio interface{} `json:"radio"`
|
||||
BatteryMv interface{} `json:"battery_mv"`
|
||||
UptimeSecs interface{} `json:"uptime_secs"`
|
||||
NoiseFloor interface{} `json:"noise_floor"`
|
||||
LastPacketAt interface{} `json:"last_packet_at"`
|
||||
// #1865 follow-up: timestamp of the observer's most recent /neighbors
|
||||
// MQTT report. nil means never reported -- opt-in firmware feature,
|
||||
// unavailable on non-PSRAM hardware, NOT a fault (cwichura on PR #1867).
|
||||
LastNeighborsReportAt interface{} `json:"last_neighbors_report_at"`
|
||||
PacketsLastHour int `json:"packetsLastHour"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
NodeRole interface{} `json:"nodeRole"`
|
||||
// Issue #1478: surface naive-clock observers to the UI.
|
||||
// `clock_naive` is derived from clock_last_naive_at being within the
|
||||
// last 24h; once decayed, all three skew fields read as zero/null so the
|
||||
|
||||
Reference in New Issue
Block a user