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:
dborup
2026-07-26 18:13:23 +02:00
parent 0a55abe533
commit 96059a5b11
3 changed files with 140 additions and 26 deletions
@@ -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
View File
@@ -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
View File
@@ -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